Skip to main content

gam_solve/reml/
firth.rs

1use super::*;
2use crate::mixture_link::fisher_weight_jet5_for_inverse_link;
3use gam_linalg::matrix::symmetrize_in_place;
4use gam_problem::InverseLink;
5
6pub(crate) const FIRTH_DERIVATIVE_PARALLEL_MIN_N: usize = 16_384;
7
8/// Reciprocal-condition-number floor below which the reduced Fisher information
9/// `I_r` is flagged as near-singular (a diagnostic warning only, not a hard
10/// gate). At `λ_min/λ_max < 1e-10` the SPD assumption on the identifiable
11/// subspace is numerically fragile and the exact pseudodet derivatives may be
12/// ill-conditioned near active-subspace boundaries.
13pub(crate) const FIRTH_REDUCED_FISHER_RCOND_WARN: f64 = 1e-10;
14
15/// β-dependent reduced-space pieces of the Firth/Jeffreys operator at the
16/// current `η`, produced by `FirthDenseOperator::firth_reduced_core` from a
17/// cached β-independent [`FirthDesignFactor`]. The full operator build consumes
18/// every field; the lightweight PIRLS-diagnostics path consumes only `w`, `w1`,
19/// `h_diag`, and `half_log_det`.
20struct FirthReducedCore {
21    w: Array1<f64>,
22    w1: Array1<f64>,
23    w2: Array1<f64>,
24    w3: Array1<f64>,
25    w4: Array1<f64>,
26    k_reduced: Array2<f64>,
27    half_log_det: f64,
28    h_diag: Array1<f64>,
29}
30
31/// Single-index sub-blocks of the exact mixed second directional derivative
32/// `D²H_φ[u,v]`, precomputed once against the fixed `eye` rhs used by the
33/// exact-Hessian TK outer loop. See
34/// [`FirthDenseOperator::tk_second_direction_eye_cache`] (#1575).
35pub(crate) struct FirthSecondDirEyeCache {
36    /// The fixed identity rhs (`p×p`), kept so per-pair `fast_ab(.., &eye)`
37    /// matmuls reproduce the original byte-for-byte.
38    eye: Array2<f64>,
39    /// `X·I` — index-independent.
40    eta_rhs: Array2<f64>,
41    /// `(Bᵀ P B-base)·I` — index-independent.
42    p_b_rhs: Array2<f64>,
43    /// Per-direction `apply_hadamard_gram(eta_rhs ⊙ b_uvec_i)`.
44    p_bx: Vec<Array2<f64>>,
45    /// Per-direction `apply_p_u(a_u_reduced_i, w' ⊙ eta_rhs)`.
46    pu_qv: Vec<Array2<f64>>,
47}
48
49impl<'a> RemlState<'a> {
50    pub(crate) fn xt_diag_x_dense_into(
51        x: &Array2<f64>,
52        diag: &Array1<f64>,
53        weighted: &mut Array2<f64>,
54    ) -> Array2<f64> {
55        super::assembly::xt_diag_x_dense_into(x, diag, weighted)
56    }
57
58    #[inline]
59    pub(crate) fn parallelize_firth_derivative_rows(n: usize) -> bool {
60        n >= FIRTH_DERIVATIVE_PARALLEL_MIN_N && rayon::current_num_threads() > 1
61    }
62
63    pub(crate) fn row_scale(x: &Array2<f64>, scale: &Array1<f64>) -> Array2<f64> {
64        let mut out = Array2::<f64>::zeros(x.raw_dim());
65        super::assembly::row_scale_dense_into(x, scale, &mut out);
66        out
67    }
68
69    #[inline]
70    pub(crate) fn dense_product_likely_uses_inner_parallelism(
71        m: usize,
72        n: usize,
73        k: usize,
74    ) -> bool {
75        // Keep this in sync with faer_ndarray::matmul_parallelism.  When a
76        // dense product is large enough for faer/BLAS-style internal
77        // parallelism, do not also wrap sibling products in rayon::join: that
78        // can oversubscribe CPU threads and slow down the REML hot path.
79        const PAR_MIN_FLOP_SCALE: usize = 2_000_000;
80        const PAR_MIN_LONG_DIM: usize = 256;
81        let flop_scale = m.saturating_mul(n).saturating_mul(k);
82        let long_dim = m.max(n).max(k);
83        flop_scale >= PAR_MIN_FLOP_SCALE && long_dim >= PAR_MIN_LONG_DIM
84    }
85
86    #[inline]
87    pub(crate) fn should_join_independent_dense_products(
88        products: &[(usize, usize, usize)],
89    ) -> bool {
90        const JOIN_MIN_TOTAL_FLOP_SCALE: usize = 128 * 1024;
91        if rayon::current_num_threads() <= 1 {
92            return false;
93        }
94        let mut total_flop_scale = 0usize;
95        for &(m, n, k) in products {
96            if Self::dense_product_likely_uses_inner_parallelism(m, n, k) {
97                return false;
98            }
99            total_flop_scale =
100                total_flop_scale.saturating_add(m.saturating_mul(n).saturating_mul(k));
101        }
102        total_flop_scale >= JOIN_MIN_TOTAL_FLOP_SCALE
103    }
104
105    /// Undo the fixed observation-weight row scale used by Firth reduced
106    /// designs.
107    ///
108    /// These stored fixed case-weight roots and reciprocals map reduced design
109    /// derivatives back to raw design space. PIRLS's moving sparse Gram uses
110    /// the signed asymmetric factorization `(X^T W) X` and has no root cache.
111    #[inline]
112    pub(crate) fn scale_rows_by_inverse_observation_weight_sqrt(
113        out: &mut Array2<f64>,
114        observation_weight_sqrt: Option<&Array1<f64>>,
115    ) {
116        let Some(scale) = observation_weight_sqrt else {
117            return;
118        };
119        super::assembly::row_scale_dense_in_place_by_inverse_positive_or_zero(out, scale);
120    }
121
122    /// GLM Fisher working-weight 5-jet for the requested inverse link. For
123    /// standard Logit this is byte-identical to the historical
124    /// `logit_inverse_link_jet5(eta).d1..d5` path that the Firth operator used
125    /// before the weights were generalized to arbitrary inverse links.
126    #[inline]
127    pub(crate) fn fisher_weight_derivatives(
128        link: &InverseLink,
129        eta: f64,
130    ) -> Result<(f64, f64, f64, f64, f64), EstimationError> {
131        fisher_weight_jet5_for_inverse_link(link, eta)
132    }
133
134    #[inline]
135    pub(crate) fn cholesky_pivots_are_numerically_resolved(chol_diag: &Array1<f64>) -> bool {
136        let mut min_pivot_sq = f64::INFINITY;
137        let mut max_pivot_sq = 0.0_f64;
138        for &pivot in chol_diag {
139            if !pivot.is_finite() || pivot <= 0.0 {
140                return false;
141            }
142            let pivot_sq = pivot * pivot;
143            min_pivot_sq = min_pivot_sq.min(pivot_sq);
144            max_pivot_sq = max_pivot_sq.max(pivot_sq);
145        }
146        if !min_pivot_sq.is_finite() {
147            return false;
148        }
149        let scale = max_pivot_sq.max(1.0);
150        let floor = (chol_diag.len().max(1) as f64) * f64::EPSILON * scale;
151        min_pivot_sq > floor
152    }
153
154    pub(crate) fn reduced_fisher_inverse_and_half_logdet(
155        fisher_reduced: &Array2<f64>,
156    ) -> Result<(Array2<f64>, f64), EstimationError> {
157        let r = fisher_reduced.nrows();
158        assert_eq!(r, fisher_reduced.ncols());
159        let mut k_reduced = Array2::<f64>::zeros((r, r));
160        if r == 0 {
161            return Ok((k_reduced, 0.0));
162        }
163
164        if let Ok(chol) = fisher_reduced.cholesky(Side::Lower) {
165            let chol_diag = chol.diag();
166            if Self::cholesky_pivots_are_numerically_resolved(&chol_diag) {
167                let half_log_det = chol_diag.iter().map(|d| d.ln()).sum::<f64>();
168                for col in 0..r {
169                    let mut e_col = Array1::<f64>::zeros(r);
170                    e_col[col] = 1.0;
171                    let solved = chol.solvevec(&e_col);
172                    k_reduced.column_mut(col).assign(&solved);
173                }
174                return Ok((k_reduced, half_log_det));
175            }
176        }
177
178        let (evals_ir, evecs_ir) = fisher_reduced
179            .eigh(Side::Lower)
180            .map_err(EstimationError::EigendecompositionFailed)?;
181        let max_eval = evals_ir.iter().copied().fold(0.0_f64, f64::max).max(1.0);
182        let tol = (r.max(1) as f64) * f64::EPSILON * max_eval;
183        let mut kept_positive_direction = false;
184        let mut half_log_det = 0.0_f64;
185        for (eig_idx, &eig) in evals_ir.iter().enumerate() {
186            if eig > tol {
187                kept_positive_direction = true;
188                half_log_det += 0.5 * eig.ln();
189                let inv = eig.recip();
190                let vec = evecs_ir.column(eig_idx).to_owned();
191                for row in 0..r {
192                    for col in 0..r {
193                        k_reduced[[row, col]] += inv * vec[row] * vec[col];
194                    }
195                }
196            }
197        }
198        if !kept_positive_direction {
199            return Err(EstimationError::ModelIsIllConditioned {
200                condition_number: f64::INFINITY,
201            });
202        }
203        Ok((k_reduced, half_log_det))
204    }
205
206    pub(crate) fn fill_fisher_weight_derivative_arrays(
207        link: &InverseLink,
208        eta: &Array1<f64>,
209        w: &mut Array1<f64>,
210        w1: &mut Array1<f64>,
211        w2: &mut Array1<f64>,
212        w3: &mut Array1<f64>,
213        w4: &mut Array1<f64>,
214    ) -> Result<(), EstimationError> {
215        assert_eq!(eta.len(), w.len());
216        assert_eq!(eta.len(), w1.len());
217        assert_eq!(eta.len(), w2.len());
218        assert_eq!(eta.len(), w3.len());
219        assert_eq!(eta.len(), w4.len());
220
221        if Self::parallelize_firth_derivative_rows(eta.len()) {
222            let values: Result<Vec<_>, EstimationError> = eta
223                .par_iter()
224                .map(|&ei| Self::fisher_weight_derivatives(link, ei))
225                .collect();
226            for (i, (value, first, second, third, fourth)) in values?.into_iter().enumerate() {
227                w[i] = value;
228                w1[i] = first;
229                w2[i] = second;
230                w3[i] = third;
231                w4[i] = fourth;
232            }
233            return Ok(());
234        }
235        for i in 0..eta.len() {
236            let (value, first, second, third, fourth) =
237                Self::fisher_weight_derivatives(link, eta[i])?;
238            w[i] = value;
239            w1[i] = first;
240            w2[i] = second;
241            w3[i] = third;
242            w4[i] = fourth;
243        }
244        Ok(())
245    }
246
247    pub(crate) fn weighted_cross(
248        left: &Array2<f64>,
249        right: &Array2<f64>,
250        weights: &Array1<f64>,
251    ) -> Array2<f64> {
252        assert_eq!(left.nrows(), right.nrows());
253        assert_eq!(left.nrows(), weights.len());
254        super::assembly::weighted_cross_dense(left, right, weights)
255    }
256
257    pub(crate) fn trace_product(a: &Array2<f64>, b: &Array2<f64>) -> f64 {
258        assert_eq!(a.nrows(), b.ncols());
259        assert_eq!(a.ncols(), b.nrows());
260        let elems = a.nrows().saturating_mul(a.ncols());
261        if elems >= 32 * 32 {
262            let aview = FaerArrayView::new(a);
263            let bview = FaerArrayView::new(b);
264            return faer_frob_inner(aview.as_ref(), bview.as_ref().transpose());
265        }
266        let m = a.nrows();
267        let n = a.ncols();
268        kahan_sum((0..m).map(|i| {
269            let mut acc = 0.0_f64;
270            for j in 0..n {
271                acc += a[[i, j]] * b[[j, i]];
272            }
273            acc
274        }))
275    }
276
277    pub(crate) fn reducedweighted_gram(z: &Array2<f64>, weights: &Array1<f64>) -> Array2<f64> {
278        // Returns Zᵀ diag(weights) Z (exact).
279        //
280        // Used for:
281        //   S = Zᵀ diag(v) Z               in Hadamard-Gram apply,
282        //   G_u = X_rᵀ diag(s_u) X_r       with s_u = w' ⊙ (Xu),
283        // both of which avoid constructing dense n×n intermediates.
284        let weighted = Self::row_scale(z, weights);
285        fast_atb(z, &weighted)
286    }
287
288    pub(crate) fn reduced_crossweighted_gram(
289        z_left: &Array2<f64>,
290        z_right: &Array2<f64>,
291        weights: &Array1<f64>,
292    ) -> Array2<f64> {
293        // Returns Z_leftᵀ diag(weights) Z_right (exact).
294        //
295        // This is used for explicit design-moving terms where left/right
296        // reduced designs differ (X_r vs X_{tau,r}) in Hadamard-Gram products.
297        let weighted = Self::row_scale(z_right, weights);
298        fast_atb(z_left, &weighted)
299    }
300
301    pub(crate) fn reduced_diag_gram(z: &Array2<f64>, a: &Array2<f64>) -> Array1<f64> {
302        // Returns diag(Z A Zᵀ), exact without forming dense n×n matrix.
303        //
304        // Identity:
305        //   [diag(Z A Zᵀ)]_i = z_iᵀ A z_i,
306        // where z_i is row i of Z. We compute this as rowwise dot(Z, Z A).
307        let za = fast_ab(z, a);
308        (z * &za).sum_axis(ndarray::Axis(1))
309    }
310
311    pub(crate) fn apply_hadamard_gram(
312        z: &Array2<f64>,
313        a_left: &Array2<f64>,
314        a_right: &Array2<f64>,
315        vec: &Array1<f64>,
316    ) -> Array1<f64> {
317        // Exact apply of:
318        //   y = ((Z A_left Z^T) ⊙ (Z A_right Z^T)) vec
319        // using Gram/Hadamard identity with S = Z^T diag(vec) Z:
320        //   y_i = z_i^T A_left S A_right z_i.
321        //
322        // This is the matrix-free kernel behind the Firth terms that would
323        // otherwise require dense P = M⊙M and M⊙N_u products.
324        // Complexity is O(n r^2) with r = rank(X), no n×n storage.
325        let s = Self::reducedweighted_gram(z, vec);
326        let left_s = a_left.dot(&s);
327        let t = left_s.dot(a_right);
328        Self::reduced_diag_gram(z, &t)
329    }
330
331    pub(crate) fn apply_hadamard_gram_to_matrix(
332        z: &Array2<f64>,
333        a_left: &Array2<f64>,
334        a_right: &Array2<f64>,
335        mat: &Array2<f64>,
336    ) -> Array2<f64> {
337        // Columnwise extension of apply_hadamard_gram:
338        //   out[:,j] = ((Z A_left Zᵀ) ⊙ (Z A_right Zᵀ)) mat[:,j].
339        //
340        // In the Firth derivatives this is used with:
341        //   A_left = K_r, A_right = K_r        for Hw⊙Hw actions,
342        //   A_left = K_r, A_right = A_u        for Hw⊙Nbar_u actions,
343        // and symmetric variants in mixed second-direction terms.
344        let mut out = Array2::<f64>::zeros(mat.raw_dim());
345        for col in 0..mat.ncols() {
346            let v = mat.column(col).to_owned();
347            let y = Self::apply_hadamard_gram(z, a_left, a_right, &v);
348            out.column_mut(col).assign(&y);
349        }
350        out
351    }
352
353    /// Link-aware dense Firth/Jeffreys builder. The REML callsites resolve the
354    /// Fisher-weight link via `reml_robust_jeffreys_link` and pass it here;
355    /// standard Logit reproduces the historical logit-pinned build byte-for-byte,
356    /// and stateful links flow through the same inverse-link derivative path.
357    pub(super) fn build_firth_dense_operator_for_link(
358        link: &InverseLink,
359        x_dense: &Array2<f64>,
360        eta: &Array1<f64>,
361        observation_weights: ndarray::ArrayView1<'_, f64>,
362    ) -> Result<FirthDenseOperator, EstimationError> {
363        FirthDenseOperator::build_with_observation_weights_impl(
364            link,
365            x_dense,
366            eta,
367            Some(observation_weights),
368        )
369    }
370
371    pub(super) fn firth_exact_tau_kernel(
372        op: &FirthDenseOperator,
373        x_tau: &Array2<f64>,
374        beta: &Array1<f64>,
375        include_hphi_tau_kernel: bool,
376    ) -> FirthTauExactKernel {
377        op.exact_tau_kernel(x_tau, beta, include_hphi_tau_kernel)
378    }
379
380    pub(super) fn firth_hphi_tau_partial_apply(
381        op: &FirthDenseOperator,
382        x_tau: &Array2<f64>,
383        kernel: &FirthTauPartialKernel,
384        rhs: &Array2<f64>,
385    ) -> Array2<f64> {
386        op.hphi_tau_partial_apply(x_tau, kernel, rhs)
387    }
388}
389
390impl FirthDenseOperator {
391    pub(crate) fn canonicalize_basis_column_signs(q_basis: &mut Array2<f64>) {
392        for col in 0..q_basis.ncols() {
393            let mut pivot_row = 0usize;
394            let mut pivot_abs = 0.0_f64;
395            for row in 0..q_basis.nrows() {
396                let value = q_basis[[row, col]];
397                let abs_value = value.abs();
398                if abs_value > pivot_abs {
399                    pivot_abs = abs_value;
400                    pivot_row = row;
401                }
402            }
403            if pivot_abs > 0.0 && q_basis[[pivot_row, col]] < 0.0 {
404                q_basis.column_mut(col).mapv_inplace(|v| -v);
405            }
406        }
407    }
408
409    pub(crate) fn identifiable_subspace_basis_from_gram(
410        gram: &Array2<f64>,
411    ) -> Result<(Array2<f64>, Array1<f64>), EstimationError> {
412        let p = gram.nrows();
413        assert_eq!(p, gram.ncols());
414        if p == 0 {
415            return Ok((Array2::<f64>::eye(0), Array1::<f64>::zeros(0)));
416        }
417
418        let (evals, evecs) = gram
419            .eigh(Side::Lower)
420            .map_err(EstimationError::EigendecompositionFailed)?;
421        let max_eval = evals.iter().copied().fold(0.0_f64, f64::max).max(1.0);
422        let tol = (p.max(1) as f64) * f64::EPSILON * max_eval;
423        let mut keep: Vec<usize> = evals
424            .iter()
425            .enumerate()
426            .filter_map(|(i, &value)| if value > tol { Some(i) } else { None })
427            .collect();
428        if keep.is_empty() {
429            return Err(EstimationError::ModelIsIllConditioned {
430                condition_number: f64::INFINITY,
431            });
432        }
433
434        // Use one orthonormal identifiable basis for both the full-rank and
435        // rank-deficient cases. Sorting retained modes by descending design
436        // energy makes the representation deterministic up to eigenspace
437        // degeneracy; fixing the column signs removes the remaining trivial
438        // sign ambiguity.
439        keep.sort_by(|&lhs, &rhs| evals[rhs].total_cmp(&evals[lhs]));
440        let r = keep.len();
441        let mut q_basis = Array2::<f64>::zeros((p, r));
442        let mut metric_spectrum = Array1::<f64>::zeros(r);
443        for (col_idx, eig_idx) in keep.into_iter().enumerate() {
444            q_basis.column_mut(col_idx).assign(&evecs.column(eig_idx));
445            metric_spectrum[col_idx] = evals[eig_idx];
446        }
447        Self::canonicalize_basis_column_signs(&mut q_basis);
448        Ok((q_basis, metric_spectrum))
449    }
450
451    #[inline]
452    pub(crate) fn trace_diag_product(diag: &Array1<f64>, matrix: &Array2<f64>) -> f64 {
453        assert_eq!(diag.len(), matrix.nrows());
454        assert_eq!(matrix.nrows(), matrix.ncols());
455        kahan_sum((0..diag.len()).map(|i| diag[i] * matrix[[i, i]]))
456    }
457
458    pub fn build_for_link(
459        link: &InverseLink,
460        x_dense: &Array2<f64>,
461        eta: &Array1<f64>,
462    ) -> Result<FirthDenseOperator, EstimationError> {
463        Self::build_with_observation_weights_impl(link, x_dense, eta, None)
464    }
465
466    pub fn build_with_observation_weights_for_link(
467        link: &InverseLink,
468        x_dense: &Array2<f64>,
469        eta: &Array1<f64>,
470        observation_weights: ndarray::ArrayView1<'_, f64>,
471    ) -> Result<FirthDenseOperator, EstimationError> {
472        Self::build_with_observation_weights_impl(link, x_dense, eta, Some(observation_weights))
473    }
474
475    /// Build the β-independent (design-only) factor of the Firth/Jeffreys
476    /// operator: the identifiable-subspace basis `Q`, the reduced design
477    /// `X_r = A^{1/2} X Q`, the retained design-Gram spectrum `S_r`, and the raw
478    /// design/transpose. This is the O(n·p²) Gram + O(p³) eigendecomposition +
479    /// the n×p design clones. None of it depends on `η`/β, so it is computed
480    /// ONCE per inner PIRLS solve and reused across Newton iterations (#1575).
481    ///
482    /// `build_with_observation_weights_impl` is exactly this factor followed by
483    /// the per-η remainder, so existing callers stay bit-for-bit identical.
484    pub(crate) fn build_design_factor_with_observation_weights(
485        x_dense: &Array2<f64>,
486        observation_weights: Option<ndarray::ArrayView1<'_, f64>>,
487    ) -> Result<FirthDesignFactor, EstimationError> {
488        let n = x_dense.nrows();
489        let observation_weight_sqrt = if let Some(weights) = observation_weights {
490            if weights.len() != n {
491                crate::bail_invalid_estim!(
492                    "Firth operator observation weight length {} != number of rows {}",
493                    weights.len(),
494                    n
495                );
496            }
497            let mut sqrt = Array1::<f64>::zeros(n);
498            for i in 0..n {
499                let weight = weights[i];
500                if !weight.is_finite() || weight < 0.0 {
501                    crate::bail_invalid_estim!(
502                        "Firth operator requires finite nonnegative observation weights, got {} at row {}",
503                        weight,
504                        i
505                    );
506                }
507                sqrt[i] = weight.sqrt();
508            }
509            Some(sqrt)
510        } else {
511            None
512        };
513        let basis_design = if let Some(scale) = observation_weight_sqrt.as_ref() {
514            RemlState::row_scale(x_dense, scale)
515        } else {
516            x_dense.clone()
517        };
518        // X̃ᵀX̃ Gram → identifiable-subspace basis Q and retained spectrum S_r.
519        let gram = fast_atb(&basis_design, &basis_design);
520        let (q_basis, metric_spectrum) = Self::identifiable_subspace_basis_from_gram(&gram)?;
521        let x_reduced = fast_ab(&basis_design, &q_basis);
522        let r = q_basis.ncols();
523        let mut x_metric_reduced_inv_diag = Array1::<f64>::zeros(r);
524        for col in 0..r {
525            x_metric_reduced_inv_diag[col] = metric_spectrum[col].recip();
526        }
527        let x_dense_t = x_dense.t().to_owned();
528        Ok(FirthDesignFactor {
529            x_dense: x_dense.clone(),
530            x_dense_t,
531            q_basis,
532            x_reduced,
533            observation_weight_sqrt,
534            metric_spectrum,
535            x_metric_reduced_inv_diag,
536            r,
537            n,
538        })
539    }
540
541    /// β-dependent reduced core used by [`Self::build_from_design_factor`]:
542    /// from the cached design factor and the current `η`, compute the Fisher-weight 5-jet, the reduced Fisher
543    /// inverse `K_r`, the identifiable-subspace half-log-determinant, and the hat
544    /// diagonal `h`. The operations and their order match the un-hoisted
545    /// `build_with_observation_weights_impl` exactly, so every consumer stays
546    /// bit-for-bit identical.
547    fn firth_reduced_core(
548        factor: &FirthDesignFactor,
549        link: &InverseLink,
550        eta: &Array1<f64>,
551    ) -> Result<FirthReducedCore, EstimationError> {
552        let n = factor.n;
553        if eta.len() != n {
554            crate::bail_invalid_estim!(
555                "Firth operator shape mismatch: nrows={}, eta_len={}",
556                n,
557                eta.len()
558            );
559        }
560        let r = factor.r;
561        let mut w = Array1::<f64>::zeros(n);
562        let mut w1 = Array1::<f64>::zeros(n);
563        let mut w2 = Array1::<f64>::zeros(n);
564        let mut w3 = Array1::<f64>::zeros(n);
565        let mut w4 = Array1::<f64>::zeros(n);
566        RemlState::fill_fisher_weight_derivative_arrays(
567            link, eta, &mut w, &mut w1, &mut w2, &mut w3, &mut w4,
568        )?;
569
570        // Reduced Fisher I_r = X_rᵀ W X_r on the identifiable subspace.
571        let fisher_reduced = gam_linalg::faer_ndarray::fast_xt_diag_x(&factor.x_reduced, &w);
572        if let Ok((eigvals_ir, _)) = fisher_reduced.eigh(Side::Lower) {
573            let max_ev = eigvals_ir.iter().copied().fold(0.0_f64, f64::max).max(1.0);
574            let min_ev = eigvals_ir
575                .iter()
576                .copied()
577                .filter(|v| v.is_finite() && *v > 0.0)
578                .fold(f64::INFINITY, f64::min);
579            if min_ev.is_finite() {
580                let rel = min_ev / max_ev;
581                if rel < FIRTH_REDUCED_FISHER_RCOND_WARN {
582                    log::warn!(
583                        "[REML/Firth] reduced Fisher I_r is near-singular (min/max={:.3e}/{:.3e}, rel={:.3e}); exact derivatives may be ill-conditioned near active-subspace boundaries.",
584                        min_ev,
585                        max_ev,
586                        rel
587                    );
588                }
589            }
590        }
591
592        let (k_reduced, mut half_log_det) = if r > 0 {
593            RemlState::reduced_fisher_inverse_and_half_logdet(&fisher_reduced)?
594        } else {
595            (Array2::<f64>::zeros((r, r)), 0.0)
596        };
597        if r > 0 {
598            for col in 0..r {
599                let metric_eig = factor.metric_spectrum[col];
600                half_log_det -= 0.5 * metric_eig.ln();
601            }
602        }
603        let h_diag = if r > 0 {
604            RemlState::reduced_diag_gram(&factor.x_reduced, &k_reduced)
605        } else {
606            Array1::<f64>::zeros(n)
607        };
608        Ok(FirthReducedCore {
609            w,
610            w1,
611            w2,
612            w3,
613            w4,
614            k_reduced,
615            half_log_det,
616            h_diag,
617        })
618    }
619
620    /// Rebuild the full Firth operator at a new `η` from a cached design factor.
621    /// Pure memoization: byte-identical to `build_with_observation_weights_impl`
622    /// for the same `(link, design, weights, η)`.
623    pub(crate) fn build_from_design_factor(
624        factor: &FirthDesignFactor,
625        link: &InverseLink,
626        eta: &Array1<f64>,
627    ) -> Result<FirthDenseOperator, EstimationError> {
628        let FirthReducedCore {
629            w,
630            w1,
631            w2,
632            w3,
633            w4,
634            k_reduced,
635            half_log_det,
636            h_diag,
637        } = Self::firth_reduced_core(factor, link, eta)?;
638        let b_base = RemlState::row_scale(&factor.x_dense, &w1);
639        let p_b_base = RemlState::apply_hadamard_gram_to_matrix(
640            &factor.x_reduced,
641            &k_reduced,
642            &k_reduced,
643            &b_base,
644        );
645        Ok(FirthDenseOperator {
646            x_dense: factor.x_dense.clone(),
647            x_dense_t: factor.x_dense_t.clone(),
648            q_basis: factor.q_basis.clone(),
649            x_reduced: factor.x_reduced.clone(),
650            observation_weight_sqrt: factor.observation_weight_sqrt.clone(),
651            k_reduced,
652            x_metric_reduced_inv_diag: factor.x_metric_reduced_inv_diag.clone(),
653            half_log_det,
654            h_diag,
655            w,
656            w1,
657            w2,
658            w3,
659            w4,
660            b_base,
661            p_b_base,
662        })
663    }
664
665    pub(crate) fn build_with_observation_weights_impl(
666        link: &InverseLink,
667        x_dense: &Array2<f64>,
668        eta: &Array1<f64>,
669        observation_weights: Option<ndarray::ArrayView1<'_, f64>>,
670    ) -> Result<FirthDenseOperator, EstimationError> {
671        // Precompute dense Firth objects at current β̂ for:
672        //   Φ(β) = 0.5 log|Uᵀ W U|,
673        // where U is a canonical orthonormal basis of the identifiable
674        // subspace of A^{1/2} X.
675        //
676        // Identifiability note:
677        // For rank-deficient design matrices X, I = Xᵀ W X is singular for all β
678        // (assuming w_i > 0). The mathematically coherent Jeffreys/Firth term is
679        // therefore the identifiable-subspace form:
680        //   Φ(β) = 0.5 log|Uᵀ W U|
681        //        = 0.5 log|I_r(β)| - 0.5 log|S_r|,
682        // with
683        //   I_r = X_rᵀ W X_r,
684        //   S_r = X_rᵀ X_r,
685        //   U   = X_r S_r^{-1/2}.
686        // The beta differential is still
687        //   dΦ = 0.5 tr(I_+^† dI),
688        // because S_r is beta-independent for a fixed design.
689        //
690        // For binomial-logit with finite eta, 0 < w_i <= 1/4, so W is SPD and
691        // Null(X'WX)=Null(X). Therefore singular directions are structural
692        // (from X) and independent of beta, which is why fixed-Q reduced-space
693        // derivatives are exact in this regime.
694        //
695        // This implementation uses the fixed identifiable-basis route directly:
696        //   I_r = X_rᵀ W X_r,  S_r = X_rᵀ X_r,
697        //   I_+^† = Q I_r^{-1} Qᵀ,
698        //   Φ     = 0.5 (log|I_r| - log|S_r|).
699        //
700        // Why `eta` (not `mu`) enters here:
701        //   all logistic weight derivatives are functions of eta through
702        //   mu(eta)=sigmoid(eta), and exact derivative consistency is preserved by
703        //   generating (w, w', w'', w''', w'''') from one coherent eta source.
704        // Using eta avoids any mismatch from externally clamped/post-processed mu.
705        //
706        // We cache reduced operators so derivatives are exact but matrix-free in n:
707        //   K_r = I_r^{-1},  h = diag(X_r K_r X_rᵀ),  B = diag(w')X,
708        // along with logistic derivatives w', w'', w''', w'''' to evaluate:
709        //   H_φ      = ∇²_β Φ,
710        //   D H_φ[u],
711        //   D² H_φ[u,v]
712        // exactly via reduced-space products (no explicit high-order tensors).
713        //
714        // Fixed observation weights:
715        // When callers provide nonnegative case weights a_i that are constant in
716        // β, the Jeffreys information is
717        //   I(β) = Xᵀ diag(a_i w_i(η)) X.
718        // We fold those fixed a_i into the identifiable basis and reduced design
719        // via X̃ = diag(sqrt(a_i)) X, so all derivative formulas continue to use
720        // the same η-derivatives of the family Fisher weights w(η), w'(η), ....
721        //
722        // This routine is now a thin wrapper: it builds the β-independent design
723        // factor (Gram, identifiable basis Q, reduced design X_r, retained
724        // spectrum S_r — the O(n·p²) + O(p³) work) and then the β-dependent
725        // remainder at `eta`. The two helpers are split out so a single inner
726        // PIRLS solve can hoist the factor out of the per-Newton-iteration hot
727        // path (#1575) while every output here stays bit-for-bit identical.
728        //
729        // The eta-length check is kept here (before the factor build) to
730        // preserve the original error ordering for existing callers.
731        let n = x_dense.nrows();
732        if eta.len() != n {
733            crate::bail_invalid_estim!(
734                "Firth operator shape mismatch: nrows={}, eta_len={}",
735                n,
736                eta.len()
737            );
738        }
739        let factor =
740            Self::build_design_factor_with_observation_weights(x_dense, observation_weights)?;
741        Self::build_from_design_factor(&factor, link, eta)
742    }
743
744    #[inline]
745    pub(crate) fn jeffreys_logdet(&self) -> f64 {
746        self.half_log_det
747    }
748
749    /// Tangent-projected Jeffreys/Firth log-determinant `½ log|Zᵀ J Z|_+`,
750    /// where `J = Xᵀ A W(η) X` is the full p-space Fisher information at
751    /// the current `η` and `Z` is the `p × m` orthonormal basis of
752    /// `null(A_act)` produced by the active-constraint tangent projector.
753    ///
754    /// Identity: `Zᵀ J Z = (X̃ Z)ᵀ W (X̃ Z)` with `X̃ = A^{1/2} X` when
755    /// fixed observation weights are present, `X̃ = X` otherwise. The
756    /// projected log-pseudo-det uses the same positive-eigenvalue
757    /// threshold convention as the rest of the tangent-projected LAML
758    /// (`positive_eigenvalue_threshold` / `exact_pseudo_logdet`) so the
759    /// kernel that defines "active subspace" is consistent across the
760    /// objective, its gradient, and the Firth contribution.
761    pub(crate) fn jeffreys_logdet_projected(&self, z: ndarray::ArrayView2<'_, f64>) -> f64 {
762        use gam_linalg::faer_ndarray::{fast_ab, fast_xt_diag_x};
763        let p = self.x_dense.ncols();
764        assert_eq!(
765            z.nrows(),
766            p,
767            "jeffreys_logdet_projected: Z must have {} rows (β-space dim), got {}",
768            p,
769            z.nrows()
770        );
771        let m = z.ncols();
772        if m == 0 {
773            return 0.0;
774        }
775        // X·Z, then optional sqrt(A) row-scale → X̃·Z.
776        let z_owned = z.to_owned();
777        let xz = fast_ab(&self.x_dense, &z_owned);
778        let xtz = if let Some(scale) = self.observation_weight_sqrt.as_ref() {
779            RemlState::row_scale(&xz, scale)
780        } else {
781            xz
782        };
783        // J_T = (X̃ Z)ᵀ W (X̃ Z), symmetric m × m PSD.
784        let mut j_t = fast_xt_diag_x(&xtz, &self.w);
785        symmetrize_in_place(&mut j_t);
786        let (evals, _) = match j_t.eigh(Side::Lower) {
787            Ok(pair) => pair,
788            Err(_) => return f64::NEG_INFINITY,
789        };
790        let Some(evals_slice) = evals.as_slice() else {
791            return f64::NEG_INFINITY;
792        };
793        let threshold = super::reml_outer_engine::positive_eigenvalue_threshold(evals_slice);
794        0.5 * super::reml_outer_engine::exact_pseudo_logdet(evals_slice, threshold)
795    }
796
797    #[inline]
798    pub(crate) fn jeffreys_beta_gradient(&self) -> Array1<f64> {
799        // For I(β) = Xᵀ A W(η) X with fixed observation weights A,
800        //   ∂/∂β_j [0.5 log|I|]
801        //   = 0.5 Σ_i h_i w_i'(η_i) x_{ij},
802        // where h_i = [A^{1/2} X I^{-1} Xᵀ A^{1/2}]_{ii}.
803        0.5 * gam_linalg::faer_ndarray::fast_av(&self.x_dense_t, &(&self.w1 * &self.h_diag))
804    }
805
806    #[inline]
807    pub fn jeffreys_logdet_and_beta_gradient(&self) -> (f64, Array1<f64>) {
808        (self.jeffreys_logdet(), self.jeffreys_beta_gradient())
809    }
810
811    #[inline]
812    pub(crate) fn reduce_explicit_design(&self, x: &Array2<f64>) -> Array2<f64> {
813        let mut reduced = fast_ab(x, &self.q_basis);
814        if let Some(scale) = self.observation_weight_sqrt.as_ref() {
815            reduced = RemlState::row_scale(&reduced, scale);
816        }
817        reduced
818    }
819
820    pub(crate) fn direction_from_deta(&self, deta: Array1<f64>) -> FirthDirection {
821        // Directional building blocks for u:
822        //   δη_u = X u
823        //   I_u  = Xᵀ diag(w' ⊙ δη_u) X
824        //   T_u  = K I_u K
825        //   N_u  = X T_u Xᵀ
826        //   Dh[u] = -diag(N_u)
827        //   P_u   = D(M⊙M)[u] = -2(M⊙N_u)
828        // and B_u = diag(w'' ⊙ δη_u) X.
829        //
830        // In this implementation, active-subspace ambiguity is removed by fixed Q:
831        // K is represented by K_r = I_r^{-1} in reduced coordinates, so
832        //   A_u = K_r G_u K_r
833        // is exact for logit with finite eta (w_i > 0) and fixed rank(X).
834        // s_u is the diagonal weight for D I[u]:
835        //   D I[u] = Xᵀ diag(s_u) X,  s_u = w' ⊙ (X u).
836        let s_u = &self.w1 * &deta;
837        // G_u = X_rᵀ diag(s_u) X_r,  A_u = K_r G_u K_r.
838        // These are reduced-space forms of
839        //   I_u and T_u = K I_u K
840        // from the full-space derivation.
841        let g_u_reduced = RemlState::reducedweighted_gram(&self.x_reduced, &s_u);
842        let k_g_u = self.k_reduced.dot(&g_u_reduced);
843        let a_u_reduced = k_g_u.dot(&self.k_reduced);
844        // Dh[u] = -diag(N_u),  N_u = X T_u Xᵀ, represented here as
845        //   N_u = Z A_u Zᵀ in weighted reduced coordinates.
846        let dh = -RemlState::reduced_diag_gram(&self.x_reduced, &a_u_reduced);
847        let b_uvec = &self.w2 * &deta;
848        FirthDirection {
849            deta,
850            g_u_reduced,
851            a_u_reduced,
852            dh,
853            b_uvec,
854        }
855    }
856
857    #[inline]
858    pub(crate) fn left_scaled_xt(&self, scale: &Array1<f64>, mat: &Array2<f64>) -> Array2<f64> {
859        fast_ab(&self.x_dense_t, &(mat * &scale.view().insert_axis(Axis(1))))
860    }
861
862    #[inline]
863    pub(crate) fn apply_p_u_to_matrix(
864        &self,
865        a_u_reduced: &Array2<f64>,
866        mat: &Array2<f64>,
867    ) -> Array2<f64> {
868        let mut out = RemlState::apply_hadamard_gram_to_matrix(
869            &self.x_reduced,
870            &self.k_reduced,
871            a_u_reduced,
872            mat,
873        );
874        out.mapv_inplace(|v| -2.0 * v);
875        out
876    }
877
878    pub(crate) fn hphi_direction_apply(
879        &self,
880        dir: &FirthDirection,
881        rhs: &Array2<f64>,
882    ) -> Array2<f64> {
883        let p = self.x_dense.ncols();
884        if rhs.nrows() != p {
885            return Array2::<f64>::zeros((p, rhs.ncols()));
886        }
887        if rhs.ncols() == 0 || p == 0 {
888            return Array2::<f64>::zeros((p, rhs.ncols()));
889        }
890        // Matrix-free apply of D(Hphi)[u] to a block V:
891        //   D(Hphi)[u] V
892        // = 0.5[ Xᵀ(c_u ⊙ (X V))
893        //       - B_uᵀ P (B V) - Bᵀ P (B_u V) - Bᵀ P_u (B V) ].
894        // This avoids dense p×p materialization and is used by sparse exact
895        // trace contractions through tr(H^{-1} ·).
896        let etav = fast_ab(&self.x_dense, rhs);
897        let qv = &etav * &self.w1.view().insert_axis(Axis(1));
898        let m_qv = RemlState::apply_hadamard_gram_to_matrix(
899            &self.x_reduced,
900            &self.k_reduced,
901            &self.k_reduced,
902            &qv,
903        );
904        let buvec = &dir.b_uvec;
905        let m_buv = RemlState::apply_hadamard_gram_to_matrix(
906            &self.x_reduced,
907            &self.k_reduced,
908            &self.k_reduced,
909            &(&etav * &buvec.view().insert_axis(Axis(1))),
910        );
911        let p_u_qv = self.apply_p_u_to_matrix(&dir.a_u_reduced, &qv);
912        let c_u = &(&self.w3 * &dir.deta) * &self.h_diag + &(&self.w2 * &dir.dh);
913        let diag_term = self
914            .x_dense_t
915            .dot(&(&etav * &c_u.view().insert_axis(Axis(1))));
916        let term1 = self.left_scaled_xt(buvec, &m_qv);
917        let term2 = self.left_scaled_xt(&self.w1, &m_buv);
918        let term3 = self.left_scaled_xt(&self.w1, &p_u_qv);
919        0.5 * (diag_term - (term1 + term2 + term3))
920    }
921
922    pub(crate) fn hphi_direction(&self, dir: &FirthDirection) -> Array2<f64> {
923        let p = self.x_dense.ncols();
924        let eye = Array2::<f64>::eye(p);
925        let mut out = self.hphi_direction_apply(dir, &eye);
926        // Exact first directional derivative of H_φ:
927        //   D H_φ[u]
928        //   = 0.5 [ Xᵀ diag(c_u) X
929        //           - (B_uᵀ P B + Bᵀ P B_u + Bᵀ P_u B) ],
930        // where
931        //   c_u = w''' ⊙ δη_u ⊙ h + w'' ⊙ Dh[u],
932        //   B   = diag(w') X.
933        //
934        // Matrix-free contraction map used below:
935        //   Bᵀ P B      via apply_hadamard_gram_to_matrix(Z, K_r, K_r, B)
936        //   Bᵀ P_u B    via apply_hadamard_gram_to_matrix(Z, K_r, A_u, B), then *(-2)
937        // where P = M⊙M and P_u = -2(M⊙N_u), but M/N_u are never formed explicitly.
938        symmetrize_in_place(&mut out);
939        out
940    }
941
942    pub(crate) fn hphisecond_direction_apply(
943        &self,
944        u: &FirthDirection,
945        v: &FirthDirection,
946        rhs: &Array2<f64>,
947    ) -> Array2<f64> {
948        let p = self.x_dense.ncols();
949        if rhs.nrows() != p {
950            return Array2::<f64>::zeros((p, rhs.ncols()));
951        }
952        if rhs.ncols() == 0 || p == 0 {
953            return Array2::<f64>::zeros((p, rhs.ncols()));
954        }
955        // Exact mixed second directional derivative:
956        //   D² H_φ[u,v] = 0.5 [ Xᵀ diag(c_uv) X - D²J₂[u,v] ], J₂ = Bᵀ P B.
957        // Implemented with matrix identities for N_{u,v}, P_{u,v}, and the
958        // nine-term expansion of D²J₂[u,v].
959        //
960        // Because we parameterize Phi through fixed-rank I_r = X_rᵀ W X_r (SPD for
961        // finite-logit eta), this mixed derivative is evaluated on a smooth
962        // manifold without dynamic active-set switching in the Firth block.
963        //
964        // The nine contraction terms below are the explicit D²J₂[u,v] expansion,
965        // each computed through reduced Hadamard-Gram operators.
966        let deta_uv = &u.deta * &v.deta;
967        // Mixed reduced Gram:
968        //   G_uv = X_rᵀ diag(w'' ⊙ (Xu) ⊙ (Xv)) X_r.
969        let s_uv = &self.w2 * &deta_uv;
970        let g_uv_reduced = RemlState::reducedweighted_gram(&self.x_reduced, &s_uv);
971        let k_g_uv = self.k_reduced.dot(&g_uv_reduced);
972        let k_gv = self.k_reduced.dot(&v.g_u_reduced);
973        let k_g_u = self.k_reduced.dot(&u.g_u_reduced);
974        // Reduced form of:
975        //   T_{u,v} = K I_{u,v} K - K Iv K I_u K - K I_u K Iv K.
976        let a_uv_reduced = k_g_uv.dot(&self.k_reduced)
977            - k_gv.dot(&k_g_u).dot(&self.k_reduced)
978            - k_g_u.dot(&k_gv).dot(&self.k_reduced);
979        let d2h = -RemlState::reduced_diag_gram(&self.x_reduced, &a_uv_reduced);
980        // Implements mixed diagonal coefficient:
981        //   c_uv = w'''' ⊙ (Xu) ⊙ (Xv) ⊙ h
982        //          + w''' ⊙ ((Xu) ⊙ Dh[v] + (Xv) ⊙ Dh[u])
983        //          + w'' ⊙ D²h[u,v].
984        let c_uv = &(&(&self.w4 * &deta_uv) * &self.h_diag)
985            + &(&self.w3 * &(&u.deta * &v.dh))
986            + &(&self.w3 * &(&v.deta * &u.dh))
987            + &(&self.w2 * &d2h);
988
989        let eta_rhs = fast_ab(&self.x_dense, rhs);
990        let diag_term = fast_ab(
991            &self.x_dense_t,
992            &(&eta_rhs * &c_uv.view().insert_axis(Axis(1))),
993        );
994
995        let b_uvvec = &self.w3 * &deta_uv;
996        let b_uv_base = &self.x_dense * &b_uvvec.view().insert_axis(Axis(1));
997        let qv = &eta_rhs * &self.w1.view().insert_axis(Axis(1));
998
999        // Linearity in the rhs argument lets us precompute the expensive
1000        // Hadamard-Gram operator on the full base blocks B, B_u, Bv, B_uv once,
1001        // then post-multiply by rhs. This preserves the exact operator while
1002        // avoiding repeated O(n r^2 c) work for every rhs block.
1003        let p_b_rhs = fast_ab(&self.p_b_base, rhs);
1004        let p_bu_rhs = RemlState::apply_hadamard_gram_to_matrix(
1005            &self.x_reduced,
1006            &self.k_reduced,
1007            &self.k_reduced,
1008            &(&eta_rhs * &u.b_uvec.view().insert_axis(Axis(1))),
1009        );
1010        let p_bv_rhs = RemlState::apply_hadamard_gram_to_matrix(
1011            &self.x_reduced,
1012            &self.k_reduced,
1013            &self.k_reduced,
1014            &(&eta_rhs * &v.b_uvec.view().insert_axis(Axis(1))),
1015        );
1016        let p_buv_base = RemlState::apply_hadamard_gram_to_matrix(
1017            &self.x_reduced,
1018            &self.k_reduced,
1019            &self.k_reduced,
1020            &b_uv_base,
1021        );
1022        let p_buv_rhs = fast_ab(&p_buv_base, rhs);
1023
1024        let pv_b_rhs = self.apply_p_u_to_matrix(&v.a_u_reduced, &qv);
1025        let pv_bu_rhs = self.apply_p_u_to_matrix(
1026            &v.a_u_reduced,
1027            &(&eta_rhs * &u.b_uvec.view().insert_axis(Axis(1))),
1028        );
1029        let p_u_b_rhs = self.apply_p_u_to_matrix(&u.a_u_reduced, &qv);
1030        let p_u_bv_rhs = self.apply_p_u_to_matrix(
1031            &u.a_u_reduced,
1032            &(&eta_rhs * &v.b_uvec.view().insert_axis(Axis(1))),
1033        );
1034
1035        let p_nu_nv_base = RemlState::apply_hadamard_gram_to_matrix(
1036            &self.x_reduced,
1037            &u.a_u_reduced,
1038            &v.a_u_reduced,
1039            &self.b_base,
1040        );
1041        let p_hw_nuv_base = RemlState::apply_hadamard_gram_to_matrix(
1042            &self.x_reduced,
1043            &self.k_reduced,
1044            &a_uv_reduced,
1045            &self.b_base,
1046        );
1047        let p_uv_base = 2.0 * p_nu_nv_base - 2.0 * p_hw_nuv_base;
1048        let p_uv_rhs = fast_ab(&p_uv_base, rhs);
1049
1050        // Nine-term expansion of D²J₂[u,v] with J₂ = Bᵀ P B.
1051        let d2_terms = [
1052            self.left_scaled_xt(&b_uvvec, &p_b_rhs),
1053            self.left_scaled_xt(&self.w1, &p_buv_rhs),
1054            self.left_scaled_xt(&u.b_uvec, &p_bv_rhs),
1055            self.left_scaled_xt(&v.b_uvec, &p_bu_rhs),
1056            self.left_scaled_xt(&u.b_uvec, &pv_b_rhs),
1057            self.left_scaled_xt(&self.w1, &pv_bu_rhs),
1058            self.left_scaled_xt(&v.b_uvec, &p_u_b_rhs),
1059            self.left_scaled_xt(&self.w1, &p_u_bv_rhs),
1060            self.left_scaled_xt(&self.w1, &p_uv_rhs),
1061        ];
1062        let mut d2_j2 = Array2::<f64>::zeros((p, rhs.ncols()));
1063        for term in d2_terms {
1064            d2_j2 += &term;
1065        }
1066
1067        0.5 * (diag_term - d2_j2)
1068    }
1069
1070    /// Precompute, for a FIXED identity rhs, every sub-block of the mixed second
1071    /// directional derivative `D²H_φ[u,v]` that depends on a SINGLE direction
1072    /// index (or on nothing but the operator). The exact-Hessian TK outer loop
1073    /// (`tk_hessian_rho_canonical_logit`) evaluates `hphisecond_direction_apply`
1074    /// for every one of the `k(k+1)/2` penalty pairs against the same `eye` rhs;
1075    /// the four heavy single-index reduced Hadamard-Gram applies inside it
1076    /// (`p_bu_rhs`/`p_bv_rhs` and `p_u_b_rhs`/`pv_b_rhs`) therefore have only `k`
1077    /// distinct values but were rebuilt `O(k²)` times. Caching them once per
1078    /// index here turns that into `O(k)` of those O(n·r²·p) applies, with the
1079    /// per-pair work limited to the genuinely mixed (`u`,`v`) blocks. This is
1080    /// exact: each cached block is a pure function of `(operator, direction[i])`
1081    /// for the fixed `eye` rhs, so the contraction it feeds is bit-identical to
1082    /// `hphisecond_direction_apply(.., &eye)` (#1575).
1083    pub(crate) fn tk_second_direction_eye_cache(
1084        &self,
1085        dirs: &[FirthDirection],
1086    ) -> FirthSecondDirEyeCache {
1087        let p = self.x_dense.ncols();
1088        let eye = Array2::<f64>::eye(p);
1089        // eta_rhs = X·I and qv = w' ⊙ eta_rhs are rhs-only (index-independent).
1090        let eta_rhs = fast_ab(&self.x_dense, &eye);
1091        let qv = &eta_rhs * &self.w1.view().insert_axis(Axis(1));
1092        // p_b_rhs = (Bᵀ P B-base)·I is rhs-only; precompute it once.
1093        let p_b_rhs = fast_ab(&self.p_b_base, &eye);
1094        // Each direction's two single-index blocks are independent O(n·r²·p)
1095        // reduced Hadamard-Gram applies. Fan them across Rayon with the nested-BLAS
1096        // guard (inner faer GEMMs pinned to `Par::Seq`, no oversubscription) when
1097        // there are several directions AND more than one thread; with a single
1098        // direction (k=1) run serially so the inner GEMMs keep the global faer
1099        // pool instead of being pinned to `Par::Seq`. The result is collected in
1100        // direction order either way, so the cached blocks are identical to the
1101        // serial build — bit-for-bit at fixture scale, where the inner GEMMs are
1102        // already `Par::Seq` (#1575).
1103        let compute_blocks = |d: &FirthDirection| -> (Array2<f64>, Array2<f64>) {
1104            // p_b{u,v}_rhs: depends only on this direction's b_uvec.
1105            let p_b = RemlState::apply_hadamard_gram_to_matrix(
1106                &self.x_reduced,
1107                &self.k_reduced,
1108                &self.k_reduced,
1109                &(&eta_rhs * &d.b_uvec.view().insert_axis(Axis(1))),
1110            );
1111            // p_u_b_rhs / pv_b_rhs: depends only on a_u_reduced.
1112            let pu = self.apply_p_u_to_matrix(&d.a_u_reduced, &qv);
1113            (p_b, pu)
1114        };
1115        let (p_bx, pu_qv): (Vec<Array2<f64>>, Vec<Array2<f64>>) =
1116            if dirs.len() > 1 && rayon::current_num_threads() > 1 {
1117                use rayon::prelude::*;
1118                dirs.par_iter()
1119                    .map(|d| gam_problem::with_nested_parallel(|| compute_blocks(d)))
1120                    .unzip()
1121            } else {
1122                dirs.iter().map(compute_blocks).unzip()
1123            };
1124        FirthSecondDirEyeCache {
1125            eye,
1126            eta_rhs,
1127            p_b_rhs,
1128            p_bx,
1129            pu_qv,
1130        }
1131    }
1132
1133    /// Exact mixed second directional derivative `D²H_φ[u,v]` against the fixed
1134    /// `eye` rhs, reusing the single-index sub-blocks precomputed once by
1135    /// [`Self::tk_second_direction_eye_cache`]. Bit-identical to
1136    /// `hphisecond_direction_apply(&dirs[i], &dirs[j], &Array2::eye(p))`; only
1137    /// the redundant per-pair recomputation of the single-index blocks is
1138    /// removed (#1575).
1139    pub(crate) fn hphisecond_direction_apply_eye_cached(
1140        &self,
1141        cache: &FirthSecondDirEyeCache,
1142        dirs: &[FirthDirection],
1143        i: usize,
1144        j: usize,
1145    ) -> Array2<f64> {
1146        let u = &dirs[i];
1147        let v = &dirs[j];
1148        let p = self.x_dense.ncols();
1149        let cols = cache.eta_rhs.ncols();
1150        if p == 0 || cols == 0 {
1151            return Array2::<f64>::zeros((p, cols));
1152        }
1153        let deta_uv = &u.deta * &v.deta;
1154        let s_uv = &self.w2 * &deta_uv;
1155        let g_uv_reduced = RemlState::reducedweighted_gram(&self.x_reduced, &s_uv);
1156        let k_g_uv = self.k_reduced.dot(&g_uv_reduced);
1157        let k_gv = self.k_reduced.dot(&v.g_u_reduced);
1158        let k_g_u = self.k_reduced.dot(&u.g_u_reduced);
1159        let a_uv_reduced = k_g_uv.dot(&self.k_reduced)
1160            - k_gv.dot(&k_g_u).dot(&self.k_reduced)
1161            - k_g_u.dot(&k_gv).dot(&self.k_reduced);
1162        let d2h = -RemlState::reduced_diag_gram(&self.x_reduced, &a_uv_reduced);
1163        let c_uv = &(&(&self.w4 * &deta_uv) * &self.h_diag)
1164            + &(&self.w3 * &(&u.deta * &v.dh))
1165            + &(&self.w3 * &(&v.deta * &u.dh))
1166            + &(&self.w2 * &d2h);
1167
1168        let eta_rhs = &cache.eta_rhs;
1169        let diag_term = fast_ab(
1170            &self.x_dense_t,
1171            &(eta_rhs * &c_uv.view().insert_axis(Axis(1))),
1172        );
1173
1174        let b_uvvec = &self.w3 * &deta_uv;
1175        let b_uv_base = &self.x_dense * &b_uvvec.view().insert_axis(Axis(1));
1176
1177        // Single-index blocks reused from the cache (the O(k²)→O(k) win).
1178        let p_b_rhs = &cache.p_b_rhs;
1179        let p_bu_rhs = &cache.p_bx[i];
1180        let p_bv_rhs = &cache.p_bx[j];
1181        let p_u_b_rhs = &cache.pu_qv[i];
1182        let pv_b_rhs = &cache.pu_qv[j];
1183
1184        // Genuinely mixed (u,v) blocks — must be rebuilt per pair.
1185        let p_buv_base = RemlState::apply_hadamard_gram_to_matrix(
1186            &self.x_reduced,
1187            &self.k_reduced,
1188            &self.k_reduced,
1189            &b_uv_base,
1190        );
1191        let p_buv_rhs = fast_ab(&p_buv_base, &cache.eye);
1192
1193        let pv_bu_rhs = self.apply_p_u_to_matrix(
1194            &v.a_u_reduced,
1195            &(eta_rhs * &u.b_uvec.view().insert_axis(Axis(1))),
1196        );
1197        let p_u_bv_rhs = self.apply_p_u_to_matrix(
1198            &u.a_u_reduced,
1199            &(eta_rhs * &v.b_uvec.view().insert_axis(Axis(1))),
1200        );
1201
1202        let p_nu_nv_base = RemlState::apply_hadamard_gram_to_matrix(
1203            &self.x_reduced,
1204            &u.a_u_reduced,
1205            &v.a_u_reduced,
1206            &self.b_base,
1207        );
1208        let p_hw_nuv_base = RemlState::apply_hadamard_gram_to_matrix(
1209            &self.x_reduced,
1210            &self.k_reduced,
1211            &a_uv_reduced,
1212            &self.b_base,
1213        );
1214        let p_uv_base = 2.0 * p_nu_nv_base - 2.0 * p_hw_nuv_base;
1215        let p_uv_rhs = fast_ab(&p_uv_base, &cache.eye);
1216
1217        let d2_terms = [
1218            self.left_scaled_xt(&b_uvvec, p_b_rhs),
1219            self.left_scaled_xt(&self.w1, &p_buv_rhs),
1220            self.left_scaled_xt(&u.b_uvec, p_bv_rhs),
1221            self.left_scaled_xt(&v.b_uvec, p_bu_rhs),
1222            self.left_scaled_xt(&u.b_uvec, pv_b_rhs),
1223            self.left_scaled_xt(&self.w1, &pv_bu_rhs),
1224            self.left_scaled_xt(&v.b_uvec, p_u_b_rhs),
1225            self.left_scaled_xt(&self.w1, &p_u_bv_rhs),
1226            self.left_scaled_xt(&self.w1, &p_uv_rhs),
1227        ];
1228        let mut d2_j2 = Array2::<f64>::zeros((p, cols));
1229        for term in d2_terms {
1230            d2_j2 += &term;
1231        }
1232
1233        0.5 * (diag_term - d2_j2)
1234    }
1235
1236    pub(super) fn rowwise_dot(a: &Array2<f64>, b: &Array2<f64>) -> Array1<f64> {
1237        assert_eq!(a.nrows(), b.nrows());
1238        assert_eq!(a.ncols(), b.ncols());
1239        let mut out = Array1::<f64>::zeros(a.nrows());
1240        for i in 0..a.nrows() {
1241            let mut acc = 0.0_f64;
1242            for j in 0..a.ncols() {
1243                acc += a[[i, j]] * b[[i, j]];
1244            }
1245            out[i] = acc;
1246        }
1247        out
1248    }
1249
1250    pub(super) fn rowwise_bilinear(
1251        a: &Array2<f64>,
1252        m: &Array2<f64>,
1253        b: &Array2<f64>,
1254    ) -> Array1<f64> {
1255        // Returns vector with entries a_iᵀ M b_i for each row i.
1256        assert_eq!(a.nrows(), b.nrows());
1257        assert_eq!(a.ncols(), m.nrows());
1258        assert_eq!(b.ncols(), m.ncols());
1259        let am = fast_ab(a, m);
1260        Self::rowwise_dot(&am, b)
1261    }
1262
1263    pub(crate) fn dot_i_and_h_from_reduced(
1264        &self,
1265        x_tau_reduced: &Array2<f64>,
1266        deta: &Array1<f64>,
1267    ) -> (Array2<f64>, Array1<f64>) {
1268        // Reduced Fisher directional derivative under fixed identifiable basis:
1269        //   I_r = X_r' W X_r
1270        //   I_r,tau = X_{r,tau}' W X_r + X_r' W X_{r,tau} + X_r' W_tau X_r
1271        // with W_tau = diag(w' ⊙ eta_tau).
1272        //
1273        // Leverage derivative used by Firth score partial:
1274        //   h_i = x_{r,i}' K_r x_{r,i}, K_r = I_r^{-1}
1275        //   h_tau = 2*diag(X_{r,tau} K_r X_r') + diag(X_r K_{r,tau} X_r')
1276        //   K_{r,tau} = -K_r I_{r,tau} K_r.
1277        //
1278        // This is exactly the fixed-beta directional derivative required by
1279        //   (gphi)_tau and Phi_tau in the Jeffreys/Firth design-moving path:
1280        //   I_{r,tau}|beta = X_{r,tau}' W X_r + X_r' W X_{r,tau}
1281        //                    + X_r' diag(w' ⊙ eta_tau|beta) X_r,
1282        //   eta_tau|beta = X_tau beta.
1283        //
1284        // We return:
1285        //   dot_i  = I_{r,tau}|beta,
1286        //   dot_h  = h_tau|beta.
1287        let dw = &self.w1 * deta;
1288        let dot_i = RemlState::weighted_cross(x_tau_reduced, &self.x_reduced, &self.w)
1289            + RemlState::weighted_cross(&self.x_reduced, x_tau_reduced, &self.w)
1290            + gam_linalg::faer_ndarray::fast_xt_diag_x(&self.x_reduced, &dw);
1291
1292        let dot_k = -self.k_reduced.dot(&dot_i).dot(&self.k_reduced);
1293        let x_tauk = fast_ab(x_tau_reduced, &self.k_reduced);
1294        let dot_h_explicit = 2.0 * Self::rowwise_dot(&x_tauk, &self.x_reduced);
1295        let dot_h_implicit = Self::rowwise_dot(&fast_ab(&self.x_reduced, &dot_k), &self.x_reduced);
1296        let dot_h = dot_h_explicit + dot_h_implicit;
1297        (dot_i, dot_h)
1298    }
1299
1300    pub(crate) fn exact_tau_kernel(
1301        &self,
1302        x_tau: &Array2<f64>,
1303        beta: &Array1<f64>,
1304        include_hphi_tau_kernel: bool,
1305    ) -> FirthTauExactKernel {
1306        // Shared exact tau-partial bundle used by both dense and sparse paths:
1307        //   (gphi)_tau | beta-fixed,
1308        //   Phi_tau | beta-fixed,
1309        // and optional H_{phi,tau}|beta kernel for later matrix-free applies.
1310        //
1311        // Closed forms (reduced Fisher, fixed active subspace):
1312        //   Phi = 0.5 log|I_r| - 0.5 log|S_r|,
1313        //   I_r = X_r' W X_r, K_r = I_r^{-1},
1314        //   S_r = X_r' X_r,   diag(G_r) = diag(S_r^{-1}),
1315        //   Phi_tau|beta = 0.5 tr(K_r I_{r,tau}) - 0.5 tr(G_r S_{r,tau}).
1316        // In the canonical reduced basis used here, G_r is diagonal.
1317        //
1318        //   (gphi)_tau = Phi_beta,tau
1319        //               = 0.5 X_tau' (w1 .* h)
1320        //                 + 0.5 X'((w2 .* eta_tau) .* h + w1 .* h_tau),
1321        //   where
1322        //     h_i = x_{r,i}' K_r x_{r,i},
1323        //     h_tau = 2*diag(X_{r,tau} K_r X_r') + diag(X_r K_{r,tau} X_r'),
1324        //     K_{r,tau} = -K_r I_{r,tau} K_r.
1325        //
1326        // Phi_beta,tau is unchanged by the -0.5 log|S_r| term because S_r does
1327        // not depend on beta. Only Phi_tau gets the explicit basis-drift
1328        // subtraction.
1329        let deta_partial = gam_linalg::faer_ndarray::fast_av(x_tau, beta);
1330        let x_tau_reduced = self.reduce_explicit_design(x_tau);
1331        let (dot_i_partial, dot_h_partial) =
1332            self.dot_i_and_h_from_reduced(&x_tau_reduced, &deta_partial);
1333        let dot_s_partial =
1334            fast_atb(&x_tau_reduced, &self.x_reduced) + fast_atb(&self.x_reduced, &x_tau_reduced);
1335
1336        let first = 0.5 * gam_linalg::faer_ndarray::fast_atv(x_tau, &(&self.w1 * &self.h_diag));
1337        let secondvec =
1338            &(&(&self.w2 * &deta_partial) * &self.h_diag) + &(&self.w1 * &dot_h_partial);
1339        let second = 0.5 * gam_linalg::faer_ndarray::fast_atv(&self.x_dense, &secondvec);
1340        let gphi_tau = first + second;
1341        let phi_tau_partial = 0.5 * RemlState::trace_product(&self.k_reduced, &dot_i_partial)
1342            - 0.5 * Self::trace_diag_product(&self.x_metric_reduced_inv_diag, &dot_s_partial);
1343
1344        let tau_kernel = if include_hphi_tau_kernel {
1345            Some(self.hphi_tau_partial_prepare_from_partials(
1346                x_tau_reduced,
1347                &deta_partial,
1348                dot_h_partial,
1349                dot_i_partial,
1350            ))
1351        } else {
1352            None
1353        };
1354        FirthTauExactKernel {
1355            gphi_tau,
1356            phi_tau_partial,
1357            tau_kernel,
1358        }
1359    }
1360
1361    pub(crate) fn hphi_tau_partial_prepare_from_partials(
1362        &self,
1363        x_tau_reduced: Array2<f64>,
1364        deta_partial: &Array1<f64>,
1365        dot_h_partial: Array1<f64>,
1366        dot_i_partial: Array2<f64>,
1367    ) -> FirthTauPartialKernel {
1368        let dotw1 = &self.w2 * deta_partial;
1369        let dotw2 = &self.w3 * deta_partial;
1370        let dot_k = -self.k_reduced.dot(&dot_i_partial).dot(&self.k_reduced);
1371        FirthTauPartialKernel {
1372            deta_partial: deta_partial.clone(),
1373            dotw1,
1374            dotw2,
1375            dot_h_partial,
1376            x_tau_reduced,
1377            dot_i_partial,
1378            dot_k_reduced: dot_k,
1379        }
1380    }
1381
1382    pub(crate) fn d_beta_hphi_tau_partial_dense(
1383        &self,
1384        x_tau: &Array2<f64>,
1385        beta: &Array1<f64>,
1386        beta_direction: &Array1<f64>,
1387    ) -> Option<Array2<f64>> {
1388        if x_tau.nrows() != self.x_dense.nrows() || x_tau.ncols() != beta.len() {
1389            return None;
1390        }
1391        if !x_tau.iter().any(|value| *value != 0.0) {
1392            return None;
1393        }
1394        let tau_bundle = self.exact_tau_kernel(x_tau, beta, true);
1395        let tau_kernel = tau_bundle.tau_kernel?;
1396        let firth_direction = self.direction_from_deta(gam_linalg::faer_ndarray::fast_av(
1397            &self.x_dense,
1398            beta_direction,
1399        ));
1400        let x_tau_v = gam_linalg::faer_ndarray::fast_av(x_tau, beta_direction);
1401        let kernel = self.d_beta_hphi_tau_partial_prepare_from_partials(
1402            &tau_kernel,
1403            &tau_kernel.deta_partial,
1404            &tau_kernel.dot_i_partial,
1405            &firth_direction,
1406            &x_tau_v,
1407        );
1408        let eye = Array2::<f64>::eye(beta_direction.len());
1409        Some(self.d_beta_hphi_tau_partial_apply(x_tau, &kernel, &eye))
1410    }
1411
1412    pub(crate) fn apply_pbar_to_matrix(&self, mat: &Array2<f64>) -> Array2<f64> {
1413        // Applies P̄ = (X_r K_r X_rᵀ)⊙(X_r K_r X_rᵀ) to each column of mat.
1414        RemlState::apply_hadamard_gram_to_matrix(
1415            &self.x_reduced,
1416            &self.k_reduced,
1417            &self.k_reduced,
1418            mat,
1419        )
1420    }
1421
1422    pub(crate) fn apply_mtau_to_matrix(
1423        &self,
1424        kernel: &FirthTauPartialKernel,
1425        mat: &Array2<f64>,
1426    ) -> Array2<f64> {
1427        // Exact apply of
1428        //   M_tau = d/dtau[(P⊙P)]|_{beta fixed} = 2(P⊙P_tau)
1429        // without building dense n×n objects.
1430        //
1431        // Decomposition:
1432        //   P = Z K Zᵀ, Z = X_r
1433        //   P_tau = Z_tau K Zᵀ + Z K Z_tauᵀ + Z dotK Zᵀ
1434        // and for each vector v:
1435        //   (P⊙(Z_tau K Zᵀ))v   : rowwise bilinear with K (Zᵀdiag(v)Z) K
1436        //   (P⊙(Z K Z_tauᵀ))v   : diag_Z( K (Zᵀdiag(v)Z_tau) K )
1437        //   (P⊙(Z dotK Zᵀ))v    : Hadamard-Gram apply with (K, dotK).
1438        if mat.nrows() != self.x_dense.nrows() || mat.ncols() == 0 {
1439            return Array2::<f64>::zeros(mat.raw_dim());
1440        }
1441        let mut out = Array2::<f64>::zeros(mat.raw_dim());
1442        for col in 0..mat.ncols() {
1443            let v = mat.column(col).to_owned();
1444            let szz = RemlState::reducedweighted_gram(&self.x_reduced, &v);
1445            let mzz = self.k_reduced.dot(&szz).dot(&self.k_reduced);
1446            let t1 = Self::rowwise_bilinear(&self.x_reduced, &mzz, &kernel.x_tau_reduced);
1447
1448            let szt =
1449                RemlState::reduced_crossweighted_gram(&self.x_reduced, &kernel.x_tau_reduced, &v);
1450            let mzt = self.k_reduced.dot(&szt).dot(&self.k_reduced);
1451            let t2 = RemlState::reduced_diag_gram(&self.x_reduced, &mzt);
1452
1453            let t3 = RemlState::apply_hadamard_gram(
1454                &self.x_reduced,
1455                &self.k_reduced,
1456                &kernel.dot_k_reduced,
1457                &v,
1458            );
1459
1460            let y = 2.0 * (t1 + t2 + t3);
1461            out.column_mut(col).assign(&y);
1462        }
1463        out
1464    }
1465
1466    pub(crate) fn hphi_tau_partial_apply(
1467        &self,
1468        x_tau: &Array2<f64>,
1469        kernel: &FirthTauPartialKernel,
1470        rhs: &Array2<f64>,
1471    ) -> Array2<f64> {
1472        let p = self.x_dense.ncols();
1473        if rhs.nrows() != p {
1474            return Array2::<f64>::zeros((p, rhs.ncols()));
1475        }
1476        if rhs.ncols() == 0 || p == 0 {
1477            return Array2::<f64>::zeros((p, rhs.ncols()));
1478        }
1479        // Matrix-free block apply of Hphi,tau|beta:
1480        //   Hphi,tau|beta(V) = 0.5 [ X_tau' r(V) + X' r_tau(V) ].
1481        //
1482        // Tensor identity behind this apply:
1483        //   Hphi,tau|beta = Phi_beta,beta,tau
1484        // and for test vectors b1,b2 (matrix columns V are batched b2's):
1485        //   Phi_beta,beta,tau[b1,b2]
1486        //   = 0.5[
1487        //       tr(I^{-1} I_{b1,b2,tau})
1488        //       - tr(I^{-1} I_{b1,b2} I^{-1} I_tau)
1489        //       - tr(I^{-1} I_{b1,tau} I^{-1} I_{b2})
1490        //       - tr(I^{-1} I_{b2,tau} I^{-1} I_{b1})
1491        //       + 2 tr(I^{-1} I_{b1} I^{-1} I_{b2} I^{-1} I_tau)
1492        //     ].
1493        // This routine evaluates that form in reduced coordinates without forming
1494        // dense 3rd-order tensors explicitly.
1495        let etav = fast_ab(&self.x_dense, rhs);
1496        let etav_tau = fast_ab(x_tau, rhs);
1497        let qv = &etav * &self.w1.view().insert_axis(Axis(1));
1498        let qv_tau = &etav * &kernel.dotw1.view().insert_axis(Axis(1))
1499            + &etav_tau * &self.w1.view().insert_axis(Axis(1));
1500        let m_qv = self.apply_pbar_to_matrix(&qv);
1501        let m_qv_tau = self.apply_mtau_to_matrix(kernel, &qv) + self.apply_pbar_to_matrix(&qv_tau);
1502        let rv = &(&etav * &self.w2.view().insert_axis(Axis(1)))
1503            * &self.h_diag.view().insert_axis(Axis(1))
1504            - &(&m_qv * &self.w1.view().insert_axis(Axis(1)));
1505        let rv_tau = (&(&etav * &kernel.dotw2.view().insert_axis(Axis(1)))
1506            + &(&etav_tau * &self.w2.view().insert_axis(Axis(1))))
1507            * self.h_diag.view().insert_axis(Axis(1))
1508            + &(&etav * &self.w2.view().insert_axis(Axis(1)))
1509                * &kernel.dot_h_partial.view().insert_axis(Axis(1))
1510            - &(&m_qv * &kernel.dotw1.view().insert_axis(Axis(1))
1511                + &m_qv_tau * &self.w1.view().insert_axis(Axis(1)));
1512        0.5 * (fast_atb(x_tau, &rv) + fast_atb(&self.x_dense, &rv_tau))
1513    }
1514
1515    // ═════════════════════════════════════════════════════════════════════════
1516    //  Pair-term primitives for the Firth outer Hessian (Task #13a / #17)
1517    // ═════════════════════════════════════════════════════════════════════════
1518    //
1519    // The REML outer Hessian at a ψ=(ρ,τ) pair needs two Firth contributions
1520    // that are NOT covered by the existing single-τ primitives:
1521    //
1522    //   A.  the pure τ×τ second partial of H_φ at fixed β (pair drift inside
1523    //       the fixed-β second-derivative trace of B_i,j used by
1524    //       build_tau_tau_pair_callback).  This is the Firth analog of the
1525    //       penalty-logdet pair term in the outer-derivative cookbook.
1526    //
1527    //   B.  the β-derivative of (H_φ)_τ|_β in direction v.  This is the
1528    //       fixed-drift-derivative M_i[v] = D_β B_i[v] that
1529    //       compute_drift_deriv_traces uses through the fixed_drift_deriv
1530    //       callback in build_tau_hyper_coords.  It is currently always None
1531    //       in the Firth+Logit path, which is what makes the outer Hessian
1532    //       approximate for Firth-reweighted models.
1533    //
1534    // Both primitives operate in the reduced identifiable subspace (X_r, K_r,
1535    // S_r, Z=X_r) of the dense Firth operator and are matrix-free in n.  They
1536    // do NOT introduce any dense n×n or p×p×p object; every contraction is
1537    // routed through reduced-space Hadamard-Gram applies and rowwise
1538    // bilinear forms, in the same spirit as hphi_tau_partial_apply and
1539    // hphisecond_direction_apply.
1540    //
1541    // Both are exact in the smooth-regime operating point assumed by this
1542    // module: X SPD-full-rank on its identifiable subspace, Q held fixed
1543    // within one outer REML step (active-subspace drift enters only between
1544    // outer iterates), w_i(η) > 0 strictly positive, and β is at the P-IRLS
1545    // solution for the current ψ so β_τ is supplied by the unified evaluator
1546    // via the IFT solve.
1547    //
1548    // Symbol conventions shared with the existing operator code:
1549    //   X_r   := X Q            (reduced identifiable design)
1550    //   W     := diag(w(η))     (Fisher weights), w', w'', w''', w''''
1551    //   I_r   := X_rᵀ W X_r,  K_r := I_r^{-1},  S_r := X_rᵀ X_r
1552    //   M     := X_r K_r X_rᵀ,  P := M ⊙ M,  B := diag(w') X
1553    //   h     := diag(M)
1554    //   X_i   := ∂X/∂τ_i,  X_{r,i} := X_i Q,  η̇_i := X_i β,  etc.
1555    //   δη_v  := X v           (β-direction v),  δη_{τ,v} := X_τ v
1556    //
1557    // H_φ structural form (reduced form of ∇²_β Φ_F):
1558    //   H_φ  =  ½ [ Xᵀ diag(w'' ⊙ h) X  −  Bᵀ P B ]
1559    // where the first term arises from differentiating the Jeffreys gradient
1560    // ½ Xᵀ (w' ⊙ h) once more in β, and the second collects the IFT-mediated
1561    // β-derivative of h = diag(X_r K_r X_rᵀ) through K_r = I_r^{-1}.  This is
1562    // the same form the existing hphi_direction code implements in directional
1563    // form (cf. firth.rs hphi_direction / hphisecond_direction_apply, the
1564    // 9-term D²J₂ expansion with J₂ = BᵀPB).
1565    //
1566    // ─────────────────────────────────────────────────────────────────────────
1567    //  Primitive A — ∂²H_φ/∂τ_i ∂τ_j |_β
1568    // ─────────────────────────────────────────────────────────────────────────
1569    //
1570    // WHAT IT COMPUTES
1571    //   Given a pair of τ-drift designs (X_τ_i, X_τ_j) and optional second
1572    //   design derivative X_{τ_i τ_j}, evaluates
1573    //
1574    //     ∂²H_φ/∂τ_i ∂τ_j |_β  =  ½ [ ∂²(Xᵀ Γ X)/∂τ_i ∂τ_j
1575    //                                − ∂²(Bᵀ P B)/∂τ_i ∂τ_j ],
1576    //   with Γ := diag(w'' ⊙ h).  Acts on a p×m rhs and returns a p×m block
1577    //   (exact same contract as hphi_tau_partial_apply, but for the *second*
1578    //   mixed τ-derivative at fixed β).
1579    //
1580    // WHY (REML callsite)
1581    //   In the outer Hessian entry Ḧ_{i,j} for τ×τ pair (i,j), the fixed-β
1582    //   second drift of B_i is exactly this primitive (with the Firth sign:
1583    //   B_i = −(H_φ)_τ_i|_β + other likelihood pieces, so ∂²B_i/∂τ_j|_β
1584    //   contributes −∂²H_φ/∂τ_i∂τ_j|_β to the outer Hessian trace).  The
1585    //   existing Firth pair callback at build_tau_tau_pair_callback currently
1586    //   carries zero for this Firth contribution; wiring this primitive into
1587    //   the TauTauPairHyperOperator is the remaining step to make the τ×τ
1588    //   outer Hessian exact in the Firth-reweighted Logit path.
1589    //
1590    // DERIVATION (full chain-rule expansion, at fixed β)
1591    //
1592    //   Building blocks at fixed β (single-τ):
1593    //     İ_i      := ∂I_r/∂τ_i |_β
1594    //                = X_{r,i}ᵀ W X_r + X_rᵀ W X_{r,i} + X_rᵀ Ẇ_i X_r,
1595    //       Ẇ_i    := diag(w' ⊙ η̇_i),   η̇_i := X_i β.
1596    //     K̇_i      := ∂K_r/∂τ_i = −K_r İ_i K_r.
1597    //     ḣ_i      := ∂h/∂τ_i |_β
1598    //                = 2·diag(X_{r,i} K_r X_rᵀ) + diag(X_r K̇_i X_rᵀ).
1599    //     Ṁ_i      := ∂M/∂τ_i |_β
1600    //                = X_{r,i} K_r X_rᵀ + X_r K̇_i X_rᵀ + X_r K_r X_{r,i}ᵀ.
1601    //     Ḃ_i      := ∂B/∂τ_i |_β
1602    //                = diag(w'' ⊙ η̇_i) X + diag(w') X_i.
1603    //     Ṗ_i      := ∂P/∂τ_i = 2 (M ⊙ Ṁ_i).
1604    //     Γ̇_i     := ∂Γ/∂τ_i |_β = diag(w''' ⊙ η̇_i ⊙ h + w'' ⊙ ḣ_i).
1605    //
1606    //   Second-order building blocks:
1607    //     η̈_{ij}  := X_{ij} β                   (0 for design linear in τ)
1608    //     Ẅ_{ij} := diag(w'' ⊙ η̇_i ⊙ η̇_j
1609    //                     + w' ⊙ η̈_{ij})
1610    //
1611    //     Ï_{ij}   := ∂²I_r/∂τ_i ∂τ_j |_β
1612    //                = X_{r,ij}ᵀ W X_r  +  X_rᵀ W X_{r,ij}
1613    //                 + X_{r,i}ᵀ W X_{r,j}  +  X_{r,j}ᵀ W X_{r,i}
1614    //                 + X_{r,i}ᵀ Ẇ_j X_r  +  X_rᵀ Ẇ_j X_{r,i}
1615    //                 + X_{r,j}ᵀ Ẇ_i X_r  +  X_rᵀ Ẇ_i X_{r,j}
1616    //                 + X_rᵀ Ẅ_{ij} X_r.
1617    //
1618    //     K̈_{ij}  := ∂²K_r/∂τ_i ∂τ_j
1619    //                = −K_r Ï_{ij} K_r
1620    //                  + K_r İ_i K_r İ_j K_r
1621    //                  + K_r İ_j K_r İ_i K_r.
1622    //
1623    //     M̈_{ij}  := X_{r,ij} K_r X_rᵀ + X_r K_r X_{r,ij}ᵀ
1624    //                 + X_{r,i} K̇_j X_rᵀ + X_r K̇_j X_{r,i}ᵀ
1625    //                 + X_{r,j} K̇_i X_rᵀ + X_r K̇_i X_{r,j}ᵀ
1626    //                 + X_{r,i} K_r X_{r,j}ᵀ + X_{r,j} K_r X_{r,i}ᵀ
1627    //                 + X_r K̈_{ij} X_rᵀ.
1628    //
1629    //     P̈_{ij}  := ∂²P/∂τ_i ∂τ_j
1630    //                = 2 (Ṁ_i ⊙ Ṁ_j) + 2 (Ṁ_j ⊙ Ṁ_i) + 2 (M ⊙ M̈_{ij})
1631    //                = 4 (Ṁ_i ⊙ Ṁ_j) + 2 (M ⊙ M̈_{ij}).
1632    //
1633    //     ḧ_{ij}  := ∂²h/∂τ_i ∂τ_j |_β
1634    //                = 2·diag(X_{r,ij} K_r X_rᵀ)
1635    //                 + diag(X_r K̈_{ij} X_rᵀ)
1636    //                 + 2·diag(X_{r,i} K̇_j X_rᵀ)
1637    //                 + 2·diag(X_{r,j} K̇_i X_rᵀ)
1638    //                 + 2·diag(X_{r,i} K_r X_{r,j}ᵀ).
1639    //
1640    //     B̈_{ij}  := ∂²B/∂τ_i ∂τ_j |_β
1641    //                = diag(w''' ⊙ η̇_i ⊙ η̇_j + w'' ⊙ η̈_{ij}) X
1642    //                 + diag(w'' ⊙ η̇_i) X_j
1643    //                 + diag(w'' ⊙ η̇_j) X_i
1644    //                 + diag(w') X_{ij}.
1645    //
1646    //     Γ̈_{ij} := ∂²Γ/∂τ_i ∂τ_j |_β
1647    //                = diag( w'''' ⊙ η̇_i ⊙ η̇_j ⊙ h
1648    //                       + w''' ⊙ η̈_{ij} ⊙ h
1649    //                       + w''' ⊙ η̇_i ⊙ ḣ_j
1650    //                       + w''' ⊙ η̇_j ⊙ ḣ_i
1651    //                       + w'' ⊙ ḧ_{ij} ).
1652    //
1653    //   Diagonal-term expansion (the Xᵀ Γ X branch):
1654    //
1655    //     ∂²(Xᵀ Γ X)/∂τ_i ∂τ_j  =
1656    //         X_{ij}ᵀ Γ X  + Xᵀ Γ X_{ij}
1657    //       + X_iᵀ Γ X_j  + X_jᵀ Γ X_i
1658    //       + X_iᵀ Γ̇_j X  + Xᵀ Γ̇_j X_i
1659    //       + X_jᵀ Γ̇_i X  + Xᵀ Γ̇_i X_j
1660    //       + Xᵀ Γ̈_{ij} X.
1661    //
1662    //   9-term expansion for the BᵀPB branch (structurally identical to
1663    //   the existing β×β D²J₂[u,v] at firth.rs:~820-830 with (u,v)
1664    //   substituted by (τ_i, τ_j) and the appropriate Ḃ, B̈, Ṗ, P̈):
1665    //
1666    //     D²(BᵀPB)[τ_i,τ_j]  =
1667    //         B̈_{ij}ᵀ  P    B      +  Bᵀ       P    B̈_{ij}
1668    //       + Ḃ_iᵀ    P    Ḃ_j   +  Ḃ_jᵀ    P    Ḃ_i
1669    //       + Ḃ_iᵀ    Ṗ_j  B      +  Bᵀ       Ṗ_j  Ḃ_i
1670    //       + Ḃ_jᵀ    Ṗ_i  B      +  Bᵀ       Ṗ_i  Ḃ_j
1671    //       + Bᵀ       P̈_{ij} B.
1672    //
1673    //   Combining,
1674    //
1675    //     ∂²H_φ/∂τ_i ∂τ_j |_β  =  ½ [
1676    //         ∂²(Xᵀ Γ X)/∂τ_i ∂τ_j  −  D²(BᵀPB)[τ_i, τ_j]
1677    //     ].
1678    //
1679    // IMPLEMENTATION SKETCH (for 13b)
1680    //   • Build per-direction reduced quantities for τ_i and τ_j:
1681    //       (x_tau_reduced, η̇, İ, K̇, Ṁ operator pieces, ḣ, b_uvec = w''⊙η̇).
1682    //     The existing `dot_i_and_h_from_reduced` yields İ and ḣ already;
1683    //     the per-direction "A_u" analog is A_τ = K_r İ K_r, matching the
1684    //     FirthDirection form used by hphisecond_direction_apply.
1685    //   • Use apply_hadamard_gram_to_matrix with
1686    //       (A_left, A_right) ∈ { (K_r, K_r), (K_r, A_τ_i), (K_r, A_τ_j),
1687    //                             (A_τ_i, A_τ_j) }
1688    //     to realize P-products, Ṗ_τ-products, and the (Ṁ_i ⊙ Ṁ_j) piece of
1689    //     P̈_{ij} without forming any n×n dense intermediate.
1690    //   • The pure-second piece `X_r K̈_{ij} X_rᵀ` decomposes into three
1691    //     reduced triple products (K_r Ï_{ij} K_r, K_r İ_i K_r İ_j K_r, and
1692    //     its transpose).  All are size-r×r in reduced coordinates.
1693    //   • For design-linear-in-τ smooths, X_{ij}=0 and η̈_{ij}=0, which
1694    //     prunes many sub-terms; callers who have X_{τ_i τ_j} available
1695    //     should pass it so the primitive remains exact on curved designs.
1696    //
1697    // ─────────────────────────────────────────────────────────────────────────
1698    //  Primitive B — D_β((H_φ)_τ|_β)[v]
1699    // ─────────────────────────────────────────────────────────────────────────
1700    //
1701    // WHAT IT COMPUTES
1702    //   Given a single τ-drift design X_τ, the β-fixed Firth partial
1703    //   (H_φ)_τ|_β encoded by FirthTauPartialKernel, and a β-direction
1704    //   vector v (of length p), returns the β-derivative of (H_φ)_τ|_β
1705    //   applied to an rhs block (so output is p×m, matching the pair's
1706    //   fixed_drift_deriv callback signature DriftDerivResult).  In
1707    //   symbols:
1708    //
1709    //     D_β((H_φ)_τ|_β)[v]  =  ½ [ D_β{(∂(XᵀΓX)/∂τ)|_β}[v]
1710    //                                 −  D_β{(∂(BᵀPB)/∂τ)|_β}[v] ].
1711    //
1712    // WHY (REML callsite)
1713    //   In the exact outer Hessian assembly (compute_drift_deriv_traces in
1714    //   unified.rs), the Ḧ_{ij} entry picks up
1715    //     tr(G_ε · D_β B_i[v_j])  +  tr(G_ε · D_β B_j[v_i]).
1716    //   For τ coordinates in the Firth+Logit path, B_τ = (penalty / design
1717    //   pieces) − (H_φ)_τ|_β, so the Firth share of D_β B_τ[v] is
1718    //     − D_β((H_φ)_τ|_β)[v].
1719    //   Hooking this primitive up through a FixedDriftDerivFn (returning
1720    //   DriftDerivResult::Dense of this p×p β-v action) is exactly what
1721    //   lets build_tau_hyper_coords pass a non-None fixed_drift_deriv
1722    //   closure into the unified evaluator, closing the approximation gap
1723    //   that firth_pair_terms_unavailable currently tracks.
1724    //
1725    // DERIVATION (β-derivative of each τ-partial term in direction v)
1726    //
1727    //   β enters only through η=Xβ, so designs X, X_τ, Q, X_r are all
1728    //   β-independent; D_β acts on w(η) and its derivatives, on I_r, K_r,
1729    //   M, h, and on η̇_τ = X_τ β.
1730    //
1731    //   Primary β-derivative building blocks (matches FirthDirection with
1732    //   deta := δη_v = X v):
1733    //     I'_v  := D_β I_r[v] = X_rᵀ diag(w' ⊙ δη_v) X_r      (g_u_reduced)
1734    //     A_v   := D_β K_r[v] = −K_r I'_v K_r                  (a_u_reduced)
1735    //     dh_v  := D_β h[v]    = −diag(X_r K_r I'_v K_r X_rᵀ)
1736    //                          = diag(X_r A_v X_rᵀ)            (dh)
1737    //     (w')_v  := D_β w'[v]  = w''  ⊙ δη_v
1738    //     (w'')_v := D_β w''[v] = w''' ⊙ δη_v
1739    //     (w''')_v:= D_β w'''[v]= w''''⊙ δη_v
1740    //     δη_{τ,v} := D_β(η̇_τ)[v] = X_τ v
1741    //
1742    //   Mixed τ-β pieces:
1743    //     D_β(İ_τ)[v]
1744    //       = X_{r,τ}ᵀ diag(w'' ⊙ δη_v) X_r
1745    //        + X_rᵀ diag(w'' ⊙ δη_v) X_{r,τ}
1746    //        + X_rᵀ diag(w'' ⊙ η̇_τ ⊙ δη_v
1747    //                     + w' ⊙ δη_{τ,v}) X_r.
1748    //     D_β(K̇_τ)[v]
1749    //       = −( A_v İ_τ K_r  +  K_r D_β(İ_τ)[v] K_r
1750    //             +  K_r İ_τ A_v ).
1751    //     D_β(Ṁ_τ)[v]
1752    //       = X_{r,τ} A_v X_rᵀ
1753    //        + X_r D_β(K̇_τ)[v] X_rᵀ
1754    //        + X_r A_v X_{r,τ}ᵀ.
1755    //     D_β(ḣ_τ)[v]
1756    //       = 2·diag(X_{r,τ} A_v X_rᵀ)
1757    //        + diag(X_r D_β(K̇_τ)[v] X_rᵀ).
1758    //
1759    //   Diagonal-term β-derivative ( (X_τᵀΓX + XᵀΓX_τ + XᵀΓ̇_τ X) branch ):
1760    //     D_β(X_τᵀ Γ X + Xᵀ Γ X_τ)[v]
1761    //       = X_τᵀ Γ_v X + Xᵀ Γ_v X_τ,
1762    //       Γ_v  := D_β Γ[v] = diag((w'')_v ⊙ h + w'' ⊙ dh_v)
1763    //                        = diag(w''' ⊙ δη_v ⊙ h + w'' ⊙ dh_v).
1764    //     D_β(Xᵀ Γ̇_τ X)[v]
1765    //       = Xᵀ Γ̇_{τ,v} X,
1766    //       Γ̇_{τ,v}
1767    //        := D_β Γ̇_τ[v]
1768    //         = diag( (w''')_v ⊙ η̇_τ ⊙ h
1769    //                 + w''' ⊙ δη_{τ,v} ⊙ h
1770    //                 + w''' ⊙ η̇_τ ⊙ dh_v
1771    //                 + (w'')_v ⊙ ḣ_τ
1772    //                 + w'' ⊙ D_β(ḣ_τ)[v] )
1773    //         = diag( w'''' ⊙ η̇_τ ⊙ δη_v ⊙ h
1774    //                 + w''' ⊙ δη_{τ,v} ⊙ h
1775    //                 + w''' ⊙ η̇_τ ⊙ dh_v
1776    //                 + w''' ⊙ δη_v ⊙ ḣ_τ
1777    //                 + w'' ⊙ D_β(ḣ_τ)[v] ).
1778    //
1779    //   Cross-coupling τ-β pieces for B:
1780    //     B_v  := D_β B[v]   = diag(w'' ⊙ δη_v) X               (b_uvec)
1781    //     B_τ  := ∂B/∂τ|_β   = diag(w'' ⊙ η̇_τ) X
1782    //                         + diag(w') X_τ.
1783    //     B_{τ,v}
1784    //         := D_β B_τ[v]  = diag( w''' ⊙ η̇_τ ⊙ δη_v
1785    //                                 + w'' ⊙ δη_{τ,v} ) X
1786    //                         + diag(w'' ⊙ δη_v) X_τ.
1787    //
1788    //   BᵀPB branch — 9 terms, obtained by applying the product rule to
1789    //   ∂(BᵀPB)/∂τ = Ḃ_τᵀ P B + Bᵀ Ṗ_τ B + Bᵀ P Ḃ_τ and then taking
1790    //   D_β(·)[v] of each factor:
1791    //
1792    //     D_β(Ḃ_τᵀ P B)[v]   = B_{τ,v}ᵀ P B + Ḃ_τᵀ P_v B + Ḃ_τᵀ P B_v,
1793    //     D_β(Bᵀ Ṗ_τ B)[v]   = B_vᵀ Ṗ_τ B  + Bᵀ P_{τ,v} B + Bᵀ Ṗ_τ B_v,
1794    //     D_β(Bᵀ P Ḃ_τ)[v]   = B_vᵀ P Ḃ_τ + Bᵀ P_v Ḃ_τ + Bᵀ P B_{τ,v}.
1795    //
1796    //   Here Ḃ_τ = B_τ above, and
1797    //     P_v := D_β P[v]         = 2 (M ⊙ M_v),   M_v = X_r A_v X_rᵀ.
1798    //     Ṗ_τ := ∂P/∂τ|_β         = 2 (M ⊙ M_τ),
1799    //       M_τ = X_{r,τ} K_r X_rᵀ + X_r K̇_τ X_rᵀ + X_r K_r X_{r,τ}ᵀ.
1800    //     P_{τ,v} := D_β(Ṗ_τ)[v]  = 2 (M_v ⊙ M_τ) + 2 (M ⊙ M_{τ,v}),
1801    //       M_{τ,v} = X_{r,τ} A_v X_rᵀ + X_r D_β(K̇_τ)[v] X_rᵀ + X_r A_v X_{r,τ}ᵀ.
1802    //
1803    //   Final primitive:
1804    //
1805    //     D_β((H_φ)_τ|_β)[v]  =  ½ [
1806    //           X_τᵀ Γ_v X  + Xᵀ Γ_v X_τ  + Xᵀ Γ̇_{τ,v} X
1807    //         −  (9-term BᵀPB β-τ expansion above)
1808    //     ].
1809    //
1810    //   Applied to an rhs block `R ∈ ℝ^{p × m}`, each Xᵀ(…) X R collapses
1811    //   to n-length row scalings of (X R) followed by Xᵀ; each Bᵀ P B
1812    //   variant uses apply_hadamard_gram_to_matrix with the correct
1813    //   (A_left, A_right) ∈ { (K_r, K_r), (K_r, A_v), (K_r, K̇_τ),
1814    //     (K_r, D_β(K̇_τ)[v]), (A_v, K̇_τ), (K_r, K̇_τ) } to realize
1815    //   P, P_v, Ṗ_τ, P_{τ,v} actions.  All operators are r×r in reduced
1816    //   coordinates, matching the existing apply cost profile.
1817    //
1818    // IMPLEMENTATION SKETCH (for 13c)
1819    //   • Build `FirthDirection` from deta = X v (reuses existing
1820    //     direction_from_deta, giving I'_v, A_v, dh_v, b_uvec).
1821    //   • Build β-derivatives of the τ-specific fields of
1822    //     FirthTauPartialKernel (dotw1, dotw2, dot_h_partial, dot_k_reduced,
1823    //     and the implicit M_τ reduced-coords operator).  These become a
1824    //     new FirthTauBetaPartialKernel attached to the prepared state.
1825    //   • The apply step is then algebraically identical to
1826    //     hphi_tau_partial_apply but with every W-tensor weight replaced by
1827    //     its β-derivative in v, and every (M, K_r)-Gram replaced by the
1828    //     appropriate β-derivative Gram above.  The structure is regular
1829    //     enough that a single helper, shared with Primitive A, can absorb
1830    //     both pair dispatches.
1831    //
1832    // NOTE ON DESIGN-LINEAR SMOOTHS
1833    //   For the common case of design-linear-in-τ smooths (scale-moving
1834    //   anisotropic bases), X_i and X_τ are constant in τ, so X_{ij}=0 and
1835    //   η̈_{ij}=0.  The primitives collapse to their W-reweighted cores but
1836    //   remain matrix-free; no special fast path is needed because the
1837    //   zeroed terms simply drop out of the Hadamard-Gram assembly.
1838    //
1839    // ═════════════════════════════════════════════════════════════════════════
1840
1841    /// Primitive A — prepare step: assemble the τ_i × τ_j reduced kernel.
1842    ///
1843    /// Consumes the per-direction partial quantities produced by
1844    /// `dot_i_and_h_from_reduced` for τ_i and τ_j (plus an optional second
1845    /// design derivative X_{τ_i τ_j}), and returns a cached kernel carrying
1846    /// the M̈_{ij}, K̈_{ij}, ḧ_{ij}, Γ̈_{ij}, and B̈_{ij}-related reduced
1847    /// coordinates needed by `hphi_tau_tau_partial_apply`.
1848    ///
1849    /// This signature mirrors `hphi_tau_partial_prepare_from_partials` for
1850    /// consistency; the pair version needs both directions simultaneously
1851    /// (to realize the 9-term D² expansion) and therefore owns both
1852    /// `x_tau_{i,j}_reduced` and their η̇_i / η̇_j.
1853    ///
1854    pub(crate) fn hphi_tau_tau_partial_prepare_from_partials(
1855        &self,
1856        x_tau_i_reduced: Array2<f64>,
1857        x_tau_j_reduced: Array2<f64>,
1858        deta_i_partial: &Array1<f64>,
1859        deta_j_partial: &Array1<f64>,
1860        dot_h_i_partial: Array1<f64>,
1861        dot_h_j_partial: Array1<f64>,
1862        dot_i_i_partial: Array2<f64>,
1863        dot_i_j_partial: Array2<f64>,
1864        x_tau_tau_reduced: Option<Array2<f64>>,
1865        deta_ij_partial: Option<Array1<f64>>,
1866    ) -> FirthTauTauPartialKernel {
1867        // K̇_i = -K_r İ_i K_r;  K̇_j = -K_r İ_j K_r.
1868        let dot_k_i_reduced = -self.k_reduced.dot(&dot_i_i_partial).dot(&self.k_reduced);
1869        let dot_k_j_reduced = -self.k_reduced.dot(&dot_i_j_partial).dot(&self.k_reduced);
1870        FirthTauTauPartialKernel {
1871            x_tau_i_reduced,
1872            x_tau_j_reduced,
1873            deta_i_partial: deta_i_partial.clone(),
1874            deta_j_partial: deta_j_partial.clone(),
1875            dot_h_i_partial,
1876            dot_h_j_partial,
1877            dot_k_i_reduced,
1878            dot_k_j_reduced,
1879            dot_i_i_partial,
1880            dot_i_j_partial,
1881            x_tau_tau_reduced,
1882            deta_ij_partial,
1883        }
1884    }
1885
1886    /// Primitive A — apply step: evaluate ∂²H_φ/∂τ_i ∂τ_j |_β against a p×m
1887    /// rhs block, returning a p×m block.
1888    ///
1889    /// Contract mirrors `hphi_tau_partial_apply`: the caller passes the two
1890    /// τ-drift designs and the prepared kernel, and receives the fixed-β
1891    /// second-τ Firth drift as a dense p×m action.  Matrix-free in n.
1892    ///
1893    pub(crate) fn hphi_tau_tau_partial_apply(
1894        &self,
1895        x_tau_i: &Array2<f64>,
1896        x_tau_j: &Array2<f64>,
1897        kernel: &FirthTauTauPartialKernel,
1898        rhs: &Array2<f64>,
1899    ) -> Array2<f64> {
1900        let p = self.x_dense.ncols();
1901        if rhs.nrows() != p {
1902            return Array2::<f64>::zeros((p, rhs.ncols()));
1903        }
1904        if rhs.ncols() == 0 || p == 0 {
1905            return Array2::<f64>::zeros((p, rhs.ncols()));
1906        }
1907        let n = self.x_dense.nrows();
1908        let m = rhs.ncols();
1909
1910        // Short aliases.
1911        let z = &self.x_reduced;
1912        let x_r = &self.x_reduced;
1913        let k = &self.k_reduced;
1914        let x_ri = &kernel.x_tau_i_reduced;
1915        let x_rj = &kernel.x_tau_j_reduced;
1916        let deta_i = &kernel.deta_i_partial;
1917        let deta_j = &kernel.deta_j_partial;
1918        let dh_i = &kernel.dot_h_i_partial;
1919        let dh_j = &kernel.dot_h_j_partial;
1920        let dot_k_i = &kernel.dot_k_i_reduced;
1921        let dot_k_j = &kernel.dot_k_j_reduced;
1922        let dot_i_i = &kernel.dot_i_i_partial;
1923        let dot_i_j = &kernel.dot_i_j_partial;
1924
1925        // Optional second-design pieces: default to zero when the design is
1926        // τ-linear (η̈_{ij} = 0, X_{ij} = 0).
1927        let x_tau_tau_is_some = kernel.x_tau_tau_reduced.is_some();
1928        let x_rij_zero = Array2::<f64>::zeros(x_r.raw_dim());
1929        let x_rij: &Array2<f64> = kernel.x_tau_tau_reduced.as_ref().unwrap_or(&x_rij_zero);
1930        let zeros_n = Array1::<f64>::zeros(n);
1931        let deta_ij = kernel.deta_ij_partial.as_ref().unwrap_or(&zeros_n);
1932
1933        // ─────────────────────────────────────────────────────────────────
1934        //  η̇ vectors in β-rhs space (η_V := X V, η_{i,V} := X_i V, etc.)
1935        // ─────────────────────────────────────────────────────────────────
1936        let (eta_v, eta_i_v, eta_j_v) = if RemlState::should_join_independent_dense_products(&[
1937            (n, m, p),
1938            (n, m, p),
1939            (n, m, p),
1940        ]) {
1941            let (eta_v, (eta_i_v, eta_j_v)) = rayon::join(
1942                || fast_ab(&self.x_dense, rhs),
1943                || rayon::join(|| fast_ab(x_tau_i, rhs), || fast_ab(x_tau_j, rhs)),
1944            );
1945            (eta_v, eta_i_v, eta_j_v)
1946        } else {
1947            (
1948                fast_ab(&self.x_dense, rhs),
1949                fast_ab(x_tau_i, rhs),
1950                fast_ab(x_tau_j, rhs),
1951            )
1952        }; // n×m blocks
1953        // X_{ij} V from the reduced second-derivative design:
1954        //   reduce_explicit_design: X_{r,τ} = diag(√a) X_τ Q,
1955        //   invert:  X_{ij} = diag(1/√a) X_{r,ij} Qᵀ.
1956        let eta_ij_v: Array2<f64> = if x_tau_tau_is_some {
1957            let qt_v = fast_atb(&self.q_basis, rhs); // r×m
1958            let mut out = fast_ab(x_rij, &qt_v); // n×m in sqrt(a)-scaled space
1959            RemlState::scale_rows_by_inverse_observation_weight_sqrt(
1960                &mut out,
1961                self.observation_weight_sqrt.as_ref(),
1962            );
1963            out
1964        } else {
1965            Array2::<f64>::zeros((n, m))
1966        };
1967
1968        // ─────────────────────────────────────────────────────────────────
1969        //  Shared per-direction reduced operators
1970        //    A_τ = K İ K   (reduced analog of T_τ = K I_τ K)
1971        //    K̇_τ = -A_τ  (already cached)
1972        // ─────────────────────────────────────────────────────────────────
1973        let a_i_reduced = -dot_k_i; // K İ_i K = -K̇_i
1974        let a_j_reduced = -dot_k_j;
1975
1976        // ─────────────────────────────────────────────────────────────────
1977        //  Ï_{ij}  — second cross derivative of reduced Fisher
1978        // ─────────────────────────────────────────────────────────────────
1979        //   Ï_{ij} = X_{r,ij}ᵀ W X_r + X_rᵀ W X_{r,ij}
1980        //          + X_{r,i}ᵀ W X_{r,j} + X_{r,j}ᵀ W X_{r,i}
1981        //          + X_{r,i}ᵀ Ẇ_j X_r + X_rᵀ Ẇ_j X_{r,i}
1982        //          + X_{r,j}ᵀ Ẇ_i X_r + X_rᵀ Ẇ_i X_{r,j}
1983        //          + X_rᵀ Ẅ_{ij} X_r.
1984        //   Ẇ_α   = diag(w' ⊙ η̇_α),  Ẅ_{ij} = diag(w'' ⊙ η̇_i ⊙ η̇_j + w' ⊙ η̈_ij).
1985        let dw_i = &self.w1 * deta_i;
1986        let dw_j = &self.w1 * deta_j;
1987        let ddw_ij = &(&self.w2 * &(deta_i * deta_j)) + &(&self.w1 * deta_ij);
1988        let mut i_ddot = Array2::<f64>::zeros(k.raw_dim());
1989        if x_tau_tau_is_some {
1990            i_ddot = i_ddot + RemlState::weighted_cross(x_rij, x_r, &self.w);
1991            i_ddot = i_ddot + RemlState::weighted_cross(x_r, x_rij, &self.w);
1992        }
1993        i_ddot = i_ddot + RemlState::weighted_cross(x_ri, x_rj, &self.w);
1994        i_ddot = i_ddot + RemlState::weighted_cross(x_rj, x_ri, &self.w);
1995        i_ddot = i_ddot + RemlState::weighted_cross(x_ri, x_r, &dw_j);
1996        i_ddot = i_ddot + RemlState::weighted_cross(x_r, x_ri, &dw_j);
1997        i_ddot = i_ddot + RemlState::weighted_cross(x_rj, x_r, &dw_i);
1998        i_ddot = i_ddot + RemlState::weighted_cross(x_r, x_rj, &dw_i);
1999        i_ddot = i_ddot + gam_linalg::faer_ndarray::fast_xt_diag_x(x_r, &ddw_ij);
2000
2001        // K̈_{ij} = −K Ï K + K İ_i K İ_j K + K İ_j K İ_i K.
2002        //   Using K İ_α K = −K̇_α = a_α_reduced, the two product terms collapse to
2003        //   K İ_i K İ_j K = a_i_reduced · İ_j · K,
2004        //   K İ_j K İ_i K = a_j_reduced · İ_i · K.
2005        let k_ddot: Array2<f64> = -k.dot(&i_ddot).dot(k)
2006            + a_i_reduced.dot(dot_i_j).dot(k)
2007            + a_j_reduced.dot(dot_i_i).dot(k);
2008
2009        // ─────────────────────────────────────────────────────────────────
2010        //  ḧ_{ij}
2011        // ─────────────────────────────────────────────────────────────────
2012        //   ḧ_ij = 2 diag(X_{r,ij} K X_rᵀ)
2013        //        + diag(X_r K̈_ij X_rᵀ)
2014        //        + 2 diag(X_{r,i} K̇_j X_rᵀ)
2015        //        + 2 diag(X_{r,j} K̇_i X_rᵀ)
2016        //        + 2 diag(X_{r,i} K X_{r,j}ᵀ).
2017        // Using diag(A Bᵀ) = rowwise_dot(A, B):
2018        let dh_ij: Array1<f64> = {
2019            let r = k.ncols();
2020            let can_join = RemlState::should_join_independent_dense_products(&[
2021                (n, r, r),
2022                (n, r, r),
2023                (n, r, r),
2024                (n, r, r),
2025            ]);
2026            let (xr_kddot, ri_kdot_j, rj_kdot_i, ri_k) = if can_join {
2027                let ((xr_kddot, ri_kdot_j), (rj_kdot_i, ri_k)) = rayon::join(
2028                    || rayon::join(|| fast_ab(x_r, &k_ddot), || fast_ab(x_ri, dot_k_j)),
2029                    || rayon::join(|| fast_ab(x_rj, dot_k_i), || fast_ab(x_ri, k)),
2030                );
2031                (xr_kddot, ri_kdot_j, rj_kdot_i, ri_k)
2032            } else {
2033                (
2034                    fast_ab(x_r, &k_ddot),
2035                    fast_ab(x_ri, dot_k_j),
2036                    fast_ab(x_rj, dot_k_i),
2037                    fast_ab(x_ri, k),
2038                )
2039            };
2040
2041            let mut acc = Self::rowwise_dot(&xr_kddot, x_r);
2042            acc = acc + 2.0 * Self::rowwise_dot(&ri_kdot_j, x_r);
2043            acc = acc + 2.0 * Self::rowwise_dot(&rj_kdot_i, x_r);
2044            acc = acc + 2.0 * Self::rowwise_dot(&ri_k, x_rj);
2045            if x_tau_tau_is_some {
2046                let rij_k = fast_ab(x_rij, k);
2047                acc = acc + 2.0 * Self::rowwise_dot(&rij_k, x_r);
2048            }
2049            acc
2050        };
2051
2052        // ─────────────────────────────────────────────────────────────────
2053        //  Γ, Γ̇_i, Γ̇_j, Γ̈_ij  (diagonal row-weight n-vectors)
2054        // ─────────────────────────────────────────────────────────────────
2055        //   γ        = w'' ⊙ h
2056        //   γ̇_i     = w''' ⊙ η̇_i ⊙ h + w'' ⊙ ḣ_i
2057        //   γ̈_ij   = w'''' ⊙ η̇_i ⊙ η̇_j ⊙ h
2058        //            + w''' ⊙ η̈_ij ⊙ h
2059        //            + w''' ⊙ η̇_i ⊙ ḣ_j
2060        //            + w''' ⊙ η̇_j ⊙ ḣ_i
2061        //            + w'' ⊙ ḧ_ij
2062        let gamma = &self.w2 * &self.h_diag;
2063        let gamma_dot_i = &(&(&self.w3 * deta_i) * &self.h_diag) + &(&self.w2 * dh_i);
2064        let gamma_dot_j = &(&(&self.w3 * deta_j) * &self.h_diag) + &(&self.w2 * dh_j);
2065        let gamma_ddot = &(&(&(&self.w4 * deta_i) * deta_j) * &self.h_diag)
2066            + &(&(&(&self.w3 * deta_ij) * &self.h_diag)
2067                + &(&(&self.w3 * deta_i) * dh_j)
2068                + &(&(&self.w3 * deta_j) * dh_i)
2069                + &(&self.w2 * &dh_ij));
2070
2071        // ─────────────────────────────────────────────────────────────────
2072        //  Diagonal-term β-rhs contributions:
2073        //    ∂²(XᵀΓX)/∂τ_i∂τ_j · V
2074        //  = X_{ij}ᵀ (γ ⊙ η_V)       + Xᵀ (γ ⊙ η_{ij,V})         [if X_ij]
2075        //    + X_iᵀ (γ ⊙ η_{j,V})    + X_jᵀ (γ ⊙ η_{i,V})
2076        //    + X_iᵀ (γ̇_j ⊙ η_V)     + Xᵀ (γ̇_j ⊙ η_{i,V})
2077        //    + X_jᵀ (γ̇_i ⊙ η_V)     + Xᵀ (γ̇_i ⊙ η_{j,V})
2078        //    + Xᵀ (γ̈_ij ⊙ η_V).
2079        // ─────────────────────────────────────────────────────────────────
2080        let mut diag_term = Array2::<f64>::zeros((p, m));
2081        let gamma_col = gamma.view().insert_axis(Axis(1));
2082        let gamma_i_col = gamma_dot_i.view().insert_axis(Axis(1));
2083        let gamma_j_col = gamma_dot_j.view().insert_axis(Axis(1));
2084        let gamma_ij_col = gamma_ddot.view().insert_axis(Axis(1));
2085
2086        // X_iᵀ (γ ⊙ η_{j,V}) + X_jᵀ (γ ⊙ η_{i,V})
2087        diag_term = diag_term + fast_atb(x_tau_i, &(&eta_j_v * &gamma_col));
2088        diag_term = diag_term + fast_atb(x_tau_j, &(&eta_i_v * &gamma_col));
2089        // X_iᵀ (γ̇_j ⊙ η_V) + X_jᵀ (γ̇_i ⊙ η_V)
2090        diag_term = diag_term + fast_atb(x_tau_i, &(&eta_v * &gamma_j_col));
2091        diag_term = diag_term + fast_atb(x_tau_j, &(&eta_v * &gamma_i_col));
2092        // Xᵀ (γ̇_j ⊙ η_{i,V}) + Xᵀ (γ̇_i ⊙ η_{j,V})
2093        diag_term = diag_term + fast_ab(&self.x_dense_t, &(&eta_i_v * &gamma_j_col));
2094        diag_term = diag_term + fast_ab(&self.x_dense_t, &(&eta_j_v * &gamma_i_col));
2095        // Xᵀ (γ̈_ij ⊙ η_V)
2096        diag_term = diag_term + fast_ab(&self.x_dense_t, &(&eta_v * &gamma_ij_col));
2097        // X_{ij}ᵀ (γ ⊙ η_V) + Xᵀ (γ ⊙ η_{ij,V})
2098        if x_tau_tau_is_some {
2099            // X_{ij}ᵀ = Q X_{r,ij}ᵀ · diag(1/√a)  (inverse of the reduce shim),
2100            // but caller supplies the reduced second-derivative design.  We
2101            // form X_{ij}ᵀ Y as q_basis · (X_{r,ij}ᵀ · diag(1/√a)·Y) = Q · X_{r,ij}ᵀ (Y unscaled).
2102            // When no observation weights, X_{r,ij} = X_{ij} Q and
2103            //   X_{ij}ᵀ Y = Q X_{r,ij}ᵀ Y.
2104            let y: Array2<f64> = &eta_v * &gamma_col;
2105            let xt_ij_y: Array2<f64> = if self.observation_weight_sqrt.is_some() {
2106                let mut y_scaled = y.clone();
2107                RemlState::scale_rows_by_inverse_observation_weight_sqrt(
2108                    &mut y_scaled,
2109                    self.observation_weight_sqrt.as_ref(),
2110                );
2111                self.q_basis.dot(&x_rij.t().dot(&y_scaled))
2112            } else {
2113                self.q_basis.dot(&x_rij.t().dot(&y))
2114            };
2115            diag_term = diag_term + xt_ij_y;
2116            diag_term = diag_term + self.x_dense_t.dot(&(&eta_ij_v * &gamma_col));
2117        }
2118
2119        // ─────────────────────────────────────────────────────────────────
2120        //  BᵀPB branch — 9-term expansion.
2121        //
2122        //  Represent each B-like operator as an "n-row scaling vector for the
2123        //  X part plus tails along X_τ and X_{ij}".  For rhs V, define the
2124        //  row-scaled η-space blocks R(B) = diag(scale) X V + tails.  Then
2125        //  Bᵀ (P action) R is assembled by row-scaling and left-multiplying
2126        //  the appropriate full designs.
2127        // ─────────────────────────────────────────────────────────────────
2128
2129        // B V row-block (eta-space):  B V = diag(w') X V.
2130        let w1_col = self.w1.view().insert_axis(Axis(1));
2131        let b_v = &eta_v * &w1_col;
2132
2133        // Ḃ_i V = diag(w'' ⊙ η̇_i) X V + diag(w') X_i V.
2134        let w2_deta_i = &self.w2 * deta_i;
2135        let w2_deta_j = &self.w2 * deta_j;
2136        let w2_deta_i_col = w2_deta_i.view().insert_axis(Axis(1));
2137        let w2_deta_j_col = w2_deta_j.view().insert_axis(Axis(1));
2138        let bdot_i_v = &(&eta_v * &w2_deta_i_col) + &(&eta_i_v * &w1_col);
2139        let bdot_j_v = &(&eta_v * &w2_deta_j_col) + &(&eta_j_v * &w1_col);
2140
2141        // B̈_{ij} V =
2142        //   diag(w''' ⊙ η̇_i ⊙ η̇_j + w'' ⊙ η̈_ij) X V
2143        //   + diag(w'' ⊙ η̇_i) X_j V
2144        //   + diag(w'' ⊙ η̇_j) X_i V
2145        //   + diag(w') X_{ij} V.
2146        let w3_didj = &(&self.w3 * deta_i) * deta_j;
2147        let w2_dij = &self.w2 * deta_ij;
2148        let bddot_scale = &w3_didj + &w2_dij;
2149        let bddot_scale_col = bddot_scale.view().insert_axis(Axis(1));
2150        let mut bddot_ij_v = &eta_v * &bddot_scale_col;
2151        bddot_ij_v += &(&eta_j_v * &w2_deta_i_col);
2152        bddot_ij_v += &(&eta_i_v * &w2_deta_j_col);
2153        bddot_ij_v += &(&eta_ij_v * &w1_col);
2154
2155        // P V  (columnwise, using K ⊙ K Hadamard gram on Z = X_r).
2156        let p_bv = RemlState::apply_hadamard_gram_to_matrix(z, k, k, &b_v);
2157        let p_bddot_ij_v = RemlState::apply_hadamard_gram_to_matrix(z, k, k, &bddot_ij_v);
2158
2159        // Ṗ_i, Ṗ_j applied to B V, Ḃ_j V, Ḃ_i V — use the existing
2160        // apply_mtau_to_matrix helper, which computes 2(M ⊙ Ṁ_τ) · mat.
2161        //
2162        // Construct a lightweight "FirthTauPartialKernel"-shaped tuple only for
2163        // apply_mtau_to_matrix; we mirror its input contract inline to avoid
2164        // owning a FirthTauPartialKernel copy here.
2165        let pdot_i_bv = self.apply_mtau_from_reduced(x_ri, dot_k_i, &b_v);
2166        let pdot_j_bv = self.apply_mtau_from_reduced(x_rj, dot_k_j, &b_v);
2167        let pdot_i_bdot_j_v = self.apply_mtau_from_reduced(x_ri, dot_k_i, &bdot_j_v);
2168        let pdot_j_bdot_i_v = self.apply_mtau_from_reduced(x_rj, dot_k_j, &bdot_i_v);
2169
2170        // P Ḃ_j V and P Ḃ_i V.
2171        let p_bdot_j_v = RemlState::apply_hadamard_gram_to_matrix(z, k, k, &bdot_j_v);
2172        let p_bdot_i_v = RemlState::apply_hadamard_gram_to_matrix(z, k, k, &bdot_i_v);
2173
2174        // P̈_{ij} V = 4 (Ṁ_i ⊙ Ṁ_j) V  + 2 (M ⊙ M̈_{ij}) V.
2175        let p_ddot_b_v = self.apply_p_ddot_ij(
2176            x_r,
2177            x_ri,
2178            x_rj,
2179            x_rij,
2180            k,
2181            dot_k_i,
2182            dot_k_j,
2183            &k_ddot,
2184            x_tau_tau_is_some,
2185            &b_v,
2186        );
2187
2188        // Assemble 9 terms of D²(BᵀPB)[τ_i, τ_j] · V.
2189        //   term1 = B̈_ijᵀ P B V + Bᵀ P B̈_ij V
2190        //   term2 = Ḃ_iᵀ P Ḃ_j V + Ḃ_jᵀ P Ḃ_i V
2191        //   term3 = Ḃ_iᵀ Ṗ_j B V + Bᵀ Ṗ_j Ḃ_i V
2192        //   term4 = Ḃ_jᵀ Ṗ_i B V + Bᵀ Ṗ_i Ḃ_j V
2193        //   term5 = Bᵀ P̈_ij B V
2194        //
2195        // "Bᵀ Q V" with B = diag(w') X equals left_scaled_xt(w1, Q V).
2196        // "Ḃ_iᵀ Q V" = diag(w'' ⊙ η̇_i) X acting on the left, plus
2197        //              diag(w') X_i on the left.  In transpose:
2198        //   Ḃ_iᵀ Q V = Xᵀ (diag(w'' ⊙ η̇_i) Q V) + X_iᵀ (diag(w') Q V).
2199        // "B̈_ijᵀ Q V" mirrors B̈_ij above in transpose.
2200
2201        let apply_bdot_tau_t =
2202            |scale_deta: &Array1<f64>, x_tau_mat: &Array2<f64>, q_v: &Array2<f64>| {
2203                let scale_col = scale_deta.view().insert_axis(Axis(1));
2204                self.x_dense_t.dot(&(q_v * &scale_col)) + x_tau_mat.t().dot(&(q_v * &w1_col))
2205            };
2206
2207        let apply_bddot_ij_t = |q_v: &Array2<f64>| -> Array2<f64> {
2208            let scale_col_full = bddot_scale.view().insert_axis(Axis(1));
2209            let mut out = self.x_dense_t.dot(&(q_v * &scale_col_full));
2210            out = out + x_tau_j.t().dot(&(q_v * &w2_deta_i_col));
2211            out = out + x_tau_i.t().dot(&(q_v * &w2_deta_j_col));
2212            if x_tau_tau_is_some {
2213                // X_{ij}ᵀ (w1 ⊙ Q V)
2214                let y = q_v * &w1_col;
2215                let contrib: Array2<f64> = if self.observation_weight_sqrt.is_some() {
2216                    let mut y_scaled = y.clone();
2217                    RemlState::scale_rows_by_inverse_observation_weight_sqrt(
2218                        &mut y_scaled,
2219                        self.observation_weight_sqrt.as_ref(),
2220                    );
2221                    self.q_basis.dot(&x_rij.t().dot(&y_scaled))
2222                } else {
2223                    self.q_basis.dot(&x_rij.t().dot(&y))
2224                };
2225                out = out + contrib;
2226            }
2227            out
2228        };
2229
2230        // term1
2231        let t1a = apply_bddot_ij_t(&p_bv);
2232        let t1b = self.left_scaled_xt(&self.w1, &p_bddot_ij_v);
2233        // term2
2234        let t2a = apply_bdot_tau_t(&w2_deta_i, x_tau_i, &p_bdot_j_v);
2235        let t2b = apply_bdot_tau_t(&w2_deta_j, x_tau_j, &p_bdot_i_v);
2236        // term3: Ḃ_iᵀ Ṗ_j B V + Bᵀ Ṗ_j Ḃ_i V
2237        let t3a = apply_bdot_tau_t(&w2_deta_i, x_tau_i, &pdot_j_bv);
2238        let t3b = self.left_scaled_xt(&self.w1, &pdot_j_bdot_i_v);
2239        // term4: Ḃ_jᵀ Ṗ_i B V + Bᵀ Ṗ_i Ḃ_j V
2240        let t4a = apply_bdot_tau_t(&w2_deta_j, x_tau_j, &pdot_i_bv);
2241        let t4b = self.left_scaled_xt(&self.w1, &pdot_i_bdot_j_v);
2242        // term5
2243        let t5 = self.left_scaled_xt(&self.w1, &p_ddot_b_v);
2244
2245        let d2_bpb = t1a + t1b + t2a + t2b + t3a + t3b + t4a + t4b + t5;
2246
2247        0.5 * (diag_term - d2_bpb)
2248    }
2249
2250    /// Pair-level exact Firth kernel at fixed β for a (τ_i, τ_j) outer
2251    /// coordinate pair.
2252    ///
2253    /// Returns the two SCALAR- and P-VECTOR-valued second-derivative
2254    /// objects that the unified REML evaluator threads into
2255    /// `HyperCoordPair::{a,g}` as additive Firth contributions, plus an
2256    /// optional prepared Primitive-A `FirthTauTauPartialKernel` that the
2257    /// pair-callback can reuse for the `b_operator` action.
2258    ///
2259    /// ═════════════════════════════════════════════════════════════════════
2260    ///  DERIVATIONS (fixed β, reduced-basis identifiable coords).
2261    ///
2262    ///  Φ = 0.5 log|I_r| − 0.5 log|S_r|,   K_r = I_r⁻¹,   G_r = diag(S_r⁻¹)
2263    ///  Φ_{τ_i}|β = 0.5 tr(K_r İ_{r,i}) − 0.5 tr(G_r Ṡ_{r,i}).
2264    ///
2265    /// ┌── pair.a scalar Φ_{τ_i τ_j}|β ────────────────────────────────────┐
2266    ///  ∂/∂τ_j [0.5 tr(K_r İ_{r,i})]
2267    ///    = 0.5 tr(K̇_{r,j} İ_{r,i}) + 0.5 tr(K_r Ï_{r,ij})
2268    ///    = −0.5 tr(K_r İ_{r,j} K_r İ_{r,i}) + 0.5 tr(K_r Ï_{r,ij})
2269    ///
2270    ///  ∂/∂τ_j [−0.5 tr(G_r Ṡ_{r,i})]
2271    ///    = −0.5 tr(Ġ_{r,j} Ṡ_{r,i}) − 0.5 tr(G_r S̈_{r,ij})
2272    ///  (G_r diagonal in canonical basis →
2273    ///   Ġ_{r,j}_kk = −G_r_kk² · diag(Ṡ_{r,j})_kk.)
2274    ///
2275    ///  Ï_{r,ij} is the same 9-term Fisher cross used by Primitive A
2276    ///  (see `hphi_tau_tau_partial_apply`:i_ddot block).
2277    ///
2278    ///  S̈_{r,ij} = X_{r,ij}^T X_r + X_r^T X_{r,ij}
2279    ///            + X_{r,i}^T X_{r,j} + X_{r,j}^T X_{r,i}.
2280    /// └─────────────────────────────────────────────────────────────────┘
2281    ///
2282    /// ┌── pair.g p-vector (gΦ)_{τ_i τ_j}|β ───────────────────────────────┐
2283    ///  (gΦ)_{τ_i} = 0.5 X_{τ_i}^T (w1 ⊙ h)
2284    ///              + 0.5 X^T [ (w2 ⊙ η̇_i) ⊙ h + w1 ⊙ ḣ_i ]
2285    ///
2286    ///  Differentiating wrt τ_j at fixed β, using η̇_α = X_α β, η̈_{ij} =
2287    ///  X_{ij} β (when x_tau_tau is provided, else 0), and ḣ_α, ḧ_{ij}
2288    ///  from Primitive A:
2289    ///
2290    ///  term_A = 0.5 ∂/∂τ_j [X_{τ_i}^T (w1 ⊙ h)]
2291    ///        = 0.5 X_{τ_i τ_j}^T (w1 ⊙ h)          [if X_{ij} present]
2292    ///        + 0.5 X_{τ_i}^T [ (w2 ⊙ η̇_j) ⊙ h + w1 ⊙ ḣ_j ]
2293    ///
2294    ///  term_B = 0.5 ∂/∂τ_j [X^T · v_{τ_i}] with
2295    ///            v_{τ_i} = (w2 ⊙ η̇_i) ⊙ h + w1 ⊙ ḣ_i
2296    ///        = 0.5 X_{τ_j}^T v_{τ_i}
2297    ///        + 0.5 X^T · v̇_{τ_i,τ_j}
2298    ///
2299    ///  where the inner derivative
2300    ///  v̇_{τ_i,τ_j} = (w3 ⊙ η̇_j ⊙ η̇_i) ⊙ h   (from ∂w2 = w3 ⊙ η̇_j)
2301    ///              + (w2 ⊙ η̈_ij) ⊙ h          (from ∂η̇_i = η̈_{ij})
2302    ///              + (w2 ⊙ η̇_i) ⊙ ḣ_j        (from ∂h = ḣ_j)
2303    ///              + (w2 ⊙ η̇_j) ⊙ ḣ_i        (from ∂w1 = w2 ⊙ η̇_j, ⊙ ḣ_i)
2304    ///              +  w1 ⊙ ḧ_{ij}             (from ∂ḣ_i = ḧ_{ij}).
2305    /// └─────────────────────────────────────────────────────────────────┘
2306    ///
2307    /// ALL Ï_{r,ij}, η̈_{ij}, ḣ_i, ḧ_{ij} computations are identical to
2308    /// those already computed inside Primitive A's `hphi_tau_tau_partial_apply`.
2309    /// We replicate only the pieces needed to yield the scalar and p-vector
2310    /// outputs to avoid computing the full p×m action when unnecessary.
2311    ///
2312    pub(crate) fn exact_tau_tau_kernel(
2313        &self,
2314        x_tau_i: &Array2<f64>,
2315        x_tau_j: &Array2<f64>,
2316        x_tau_tau: Option<&Array2<f64>>,
2317        beta: &Array1<f64>,
2318        include_hphi_tau_tau_kernel: bool,
2319    ) -> FirthTauTauExactKernel {
2320        let deta_i = x_tau_i.dot(beta);
2321        let deta_j = x_tau_j.dot(beta);
2322        let deta_ij = x_tau_tau.as_ref().map(|xij| xij.dot(beta));
2323
2324        let x_tau_i_reduced = self.reduce_explicit_design(x_tau_i);
2325        let x_tau_j_reduced = self.reduce_explicit_design(x_tau_j);
2326        let x_tau_tau_reduced = x_tau_tau.map(|xij| self.reduce_explicit_design(xij));
2327
2328        let (dot_i_i, dot_h_i) = self.dot_i_and_h_from_reduced(&x_tau_i_reduced, &deta_i);
2329        let (dot_i_j, dot_h_j) = self.dot_i_and_h_from_reduced(&x_tau_j_reduced, &deta_j);
2330
2331        // Ï_{r,ij} = X_{r,ij}^T W X_r + X_r^T W X_{r,ij}
2332        //            + X_{r,i}^T W X_{r,j} + X_{r,j}^T W X_{r,i}
2333        //            + X_{r,i}^T Ẇ_j X_r + X_r^T Ẇ_j X_{r,i}
2334        //            + X_{r,j}^T Ẇ_i X_r + X_r^T Ẇ_i X_{r,j}
2335        //            + X_r^T Ẅ_{ij} X_r
2336        // Ẇ_α = diag(w' ⊙ η̇_α);  Ẅ_{ij} = diag(w'' ⊙ η̇_i ⊙ η̇_j + w' ⊙ η̈_{ij}).
2337        let zeros_n = Array1::<f64>::zeros(self.x_dense.nrows());
2338        let deta_ij_ref: &Array1<f64> = deta_ij.as_ref().unwrap_or(&zeros_n);
2339        let dw_i = &self.w1 * &deta_i;
2340        let dw_j = &self.w1 * &deta_j;
2341        let ddw_ij = &(&self.w2 * &(&deta_i * &deta_j)) + &(&self.w1 * deta_ij_ref);
2342
2343        let x_r = &self.x_reduced;
2344        let mut i_ddot = Array2::<f64>::zeros(self.k_reduced.raw_dim());
2345        if let Some(x_rij) = x_tau_tau_reduced.as_ref() {
2346            i_ddot = i_ddot + RemlState::weighted_cross(x_rij, x_r, &self.w);
2347            i_ddot = i_ddot + RemlState::weighted_cross(x_r, x_rij, &self.w);
2348        }
2349        i_ddot = i_ddot + RemlState::weighted_cross(&x_tau_i_reduced, &x_tau_j_reduced, &self.w);
2350        i_ddot = i_ddot + RemlState::weighted_cross(&x_tau_j_reduced, &x_tau_i_reduced, &self.w);
2351        i_ddot = i_ddot + RemlState::weighted_cross(&x_tau_i_reduced, x_r, &dw_j);
2352        i_ddot = i_ddot + RemlState::weighted_cross(x_r, &x_tau_i_reduced, &dw_j);
2353        i_ddot = i_ddot + RemlState::weighted_cross(&x_tau_j_reduced, x_r, &dw_i);
2354        i_ddot = i_ddot + RemlState::weighted_cross(x_r, &x_tau_j_reduced, &dw_i);
2355        i_ddot = i_ddot + gam_linalg::faer_ndarray::fast_xt_diag_x(x_r, &ddw_ij);
2356
2357        // pair.a likelihood contribution:
2358        //   0.5 tr(K_r Ï_{r,ij}) − 0.5 tr(K_r İ_{r,j} K_r İ_{r,i}).
2359        // K_r İ_{r,α} — reuse inline dot().
2360        let k = &self.k_reduced;
2361        let k_dot_i_i = k.dot(&dot_i_i);
2362        let k_dot_i_j = k.dot(&dot_i_j);
2363        let a_lik = 0.5 * RemlState::trace_product(k, &i_ddot)
2364            - 0.5 * RemlState::trace_product(&k_dot_i_j, &k_dot_i_i);
2365
2366        // pair.a penalty-basis contribution:
2367        //   Ṡ_{r,α} = X_{r,α}^T X_r + X_r^T X_{r,α}
2368        //   S̈_{r,ij} = X_{r,ij}^T X_r + X_r^T X_{r,ij}
2369        //            + X_{r,i}^T X_{r,j} + X_{r,j}^T X_{r,i}
2370        //   tr(G_r Ṡ_{r,i}) = Σ_k G_r_kk · diag(Ṡ_{r,i})_kk
2371        //   tr(Ġ_{r,j} Ṡ_{r,i}) = −Σ_k G_r_kk² · diag(Ṡ_{r,j})_kk · diag(Ṡ_{r,i})_kk
2372        let dot_s_i = fast_atb(&x_tau_i_reduced, x_r) + fast_atb(x_r, &x_tau_i_reduced);
2373        let dot_s_j = fast_atb(&x_tau_j_reduced, x_r) + fast_atb(x_r, &x_tau_j_reduced);
2374        let mut s_ddot = Array2::<f64>::zeros(k.raw_dim());
2375        if let Some(x_rij) = x_tau_tau_reduced.as_ref() {
2376            s_ddot = s_ddot + fast_atb(x_rij, x_r) + fast_atb(x_r, x_rij);
2377        }
2378        s_ddot = s_ddot
2379            + fast_atb(&x_tau_i_reduced, &x_tau_j_reduced)
2380            + fast_atb(&x_tau_j_reduced, &x_tau_i_reduced);
2381        // With G_r = diag(g) in the canonical reduced basis (where
2382        // S_r is diagonal), S_r + τ·Ṡ is generally non-diagonal under
2383        // perturbation, so Ġ_j = −G Ṡ_j G picks up OFF-DIAGONAL terms:
2384        //     (Ġ_j)_{kl} = −G_k · (Ṡ_j)_{kl} · G_l.
2385        // Hence tr(Ġ_j Ṡ_i) = −Σ_{k,l} G_k G_l (Ṡ_j)_{kl} (Ṡ_i)_{lk}.
2386        // Using symmetry of Ṡ_i (and Ṡ_j):
2387        //     −0.5 tr(Ġ_j Ṡ_i) = +0.5 Σ_{k,l} G_k G_l (Ṡ_j)_{kl} (Ṡ_i)_{kl}.
2388        // The S̈_{ij} trace against diagonal G_r picks only the diagonal.
2389        let g_inv = &self.x_metric_reduced_inv_diag;
2390        let rdim = k.nrows();
2391        let mut a_pen = 0.0_f64;
2392        for kk in 0..rdim {
2393            for ll in 0..rdim {
2394                a_pen += 0.5 * g_inv[kk] * g_inv[ll] * dot_s_j[[kk, ll]] * dot_s_i[[kk, ll]];
2395            }
2396            a_pen -= 0.5 * g_inv[kk] * s_ddot[[kk, kk]];
2397        }
2398        let phi_tau_tau_partial = a_lik + a_pen;
2399
2400        // ─── pair.g p-vector: (gΦ)_{τ_i τ_j}|β ──────────────────────────
2401        //
2402        // Assemble ḧ_{ij} identically to Primitive A's body.  We need:
2403        //   K̇_{r,α} = −K_r İ_{r,α} K_r,
2404        //   K̈_{r,ij} = −K_r Ï_{r,ij} K_r + K_r İ_{r,i} K_r İ_{r,j} K_r
2405        //                                 + K_r İ_{r,j} K_r İ_{r,i} K_r.
2406        let dot_k_i = -k.dot(&dot_i_i).dot(k);
2407        let dot_k_j = -k.dot(&dot_i_j).dot(k);
2408        let a_i_red = -&dot_k_i; // K İ_i K
2409        let a_j_red = -&dot_k_j; // K İ_j K
2410        let k_ddot: Array2<f64> =
2411            -k.dot(&i_ddot).dot(k) + a_i_red.dot(&dot_i_j).dot(k) + a_j_red.dot(&dot_i_i).dot(k);
2412
2413        // ḧ_{ij} = 2 diag(X_{r,ij} K X_r^T)
2414        //        + diag(X_r K̈_{ij} X_r^T)
2415        //        + 2 diag(X_{r,i} K̇_j X_r^T)
2416        //        + 2 diag(X_{r,j} K̇_i X_r^T)
2417        //        + 2 diag(X_{r,i} K X_{r,j}^T).
2418        let n = self.x_dense.nrows();
2419        let mut dh_ij = Array1::<f64>::zeros(n);
2420        if let Some(x_rij) = x_tau_tau_reduced.as_ref() {
2421            let rij_k = x_rij.dot(k);
2422            dh_ij = dh_ij + 2.0 * Self::rowwise_dot(&rij_k, x_r);
2423        }
2424        let xr_kddot = x_r.dot(&k_ddot);
2425        dh_ij = dh_ij + Self::rowwise_dot(&xr_kddot, x_r);
2426        let ri_kdot_j = x_tau_i_reduced.dot(&dot_k_j);
2427        dh_ij = dh_ij + 2.0 * Self::rowwise_dot(&ri_kdot_j, x_r);
2428        let rj_kdot_i = x_tau_j_reduced.dot(&dot_k_i);
2429        dh_ij = dh_ij + 2.0 * Self::rowwise_dot(&rj_kdot_i, x_r);
2430        let ri_k = x_tau_i_reduced.dot(k);
2431        dh_ij = dh_ij + 2.0 * Self::rowwise_dot(&ri_k, &x_tau_j_reduced);
2432
2433        // term_A = 0.5 X_{τ_i τ_j}^T (w1 ⊙ h)
2434        //        + 0.5 X_{τ_i}^T [ (w2 ⊙ η̇_j) ⊙ h + w1 ⊙ ḣ_j ]
2435        let w1_h = &self.w1 * &self.h_diag;
2436        let mut gphi_tau_tau = Array1::<f64>::zeros(self.x_dense.ncols());
2437        if let Some(x_ij) = x_tau_tau.as_ref() {
2438            gphi_tau_tau = gphi_tau_tau + 0.5 * x_ij.t().dot(&w1_h);
2439        }
2440        let inner_j = &(&(&self.w2 * &deta_j) * &self.h_diag) + &(&self.w1 * &dot_h_j);
2441        gphi_tau_tau = gphi_tau_tau + 0.5 * x_tau_i.t().dot(&inner_j);
2442
2443        // term_B pieces:  v_{τ_i} = (w2 ⊙ η̇_i) ⊙ h + w1 ⊙ ḣ_i
2444        let v_tau_i = &(&(&self.w2 * &deta_i) * &self.h_diag) + &(&self.w1 * &dot_h_i);
2445        gphi_tau_tau = gphi_tau_tau + 0.5 * x_tau_j.t().dot(&v_tau_i);
2446
2447        // v̇_{τ_i,τ_j} =
2448        //    (w3 ⊙ η̇_j ⊙ η̇_i) ⊙ h
2449        //  + (w2 ⊙ η̈_{ij}) ⊙ h
2450        //  + (w2 ⊙ η̇_i) ⊙ ḣ_j
2451        //  + (w2 ⊙ η̇_j) ⊙ ḣ_i
2452        //  +  w1 ⊙ ḧ_{ij}.
2453        let mut v_dot_ij = &(&(&self.w3 * &deta_j) * &deta_i) * &self.h_diag;
2454        v_dot_ij += &(&(&self.w2 * deta_ij_ref) * &self.h_diag);
2455        v_dot_ij += &(&(&self.w2 * &deta_i) * &dot_h_j);
2456        v_dot_ij += &(&(&self.w2 * &deta_j) * &dot_h_i);
2457        v_dot_ij += &(&self.w1 * &dh_ij);
2458        gphi_tau_tau = gphi_tau_tau + 0.5 * self.x_dense.t().dot(&v_dot_ij);
2459
2460        let tau_tau_kernel = if include_hphi_tau_tau_kernel {
2461            Some(self.hphi_tau_tau_partial_prepare_from_partials(
2462                x_tau_i_reduced,
2463                x_tau_j_reduced,
2464                &deta_i,
2465                &deta_j,
2466                dot_h_i,
2467                dot_h_j,
2468                dot_i_i,
2469                dot_i_j,
2470                x_tau_tau_reduced,
2471                deta_ij,
2472            ))
2473        } else {
2474            None
2475        };
2476
2477        FirthTauTauExactKernel {
2478            phi_tau_tau_partial,
2479            gphi_tau_tau,
2480            tau_tau_kernel,
2481        }
2482    }
2483
2484    /// Apply `Ṗ_τ V = 2 (M ⊙ Ṁ_τ) V` given the reduced τ-drift design
2485    /// `x_tau_reduced` and the reduced Fisher-inverse drift `dot_k_reduced`.
2486    ///
2487    /// This mirrors the body of `apply_mtau_to_matrix` but accepts the
2488    /// x_tau/dot_k pieces directly, letting Primitive A reuse the same
2489    /// matrix-free Ṗ_τ applies without owning a `FirthTauPartialKernel`.
2490    pub(crate) fn apply_mtau_from_reduced(
2491        &self,
2492        x_tau_reduced: &Array2<f64>,
2493        dot_k_reduced: &Array2<f64>,
2494        mat: &Array2<f64>,
2495    ) -> Array2<f64> {
2496        if mat.nrows() != self.x_dense.nrows() || mat.ncols() == 0 {
2497            return Array2::<f64>::zeros(mat.raw_dim());
2498        }
2499        let mut out = Array2::<f64>::zeros(mat.raw_dim());
2500        for col in 0..mat.ncols() {
2501            let v = mat.column(col).to_owned();
2502            let szz = RemlState::reducedweighted_gram(&self.x_reduced, &v);
2503            let mzz = self.k_reduced.dot(&szz).dot(&self.k_reduced);
2504            let t1 = Self::rowwise_bilinear(&self.x_reduced, &mzz, x_tau_reduced);
2505
2506            let szt = RemlState::reduced_crossweighted_gram(&self.x_reduced, x_tau_reduced, &v);
2507            let mzt = self.k_reduced.dot(&szt).dot(&self.k_reduced);
2508            let t2 = RemlState::reduced_diag_gram(&self.x_reduced, &mzt);
2509
2510            let t3 =
2511                RemlState::apply_hadamard_gram(&self.x_reduced, &self.k_reduced, dot_k_reduced, &v);
2512
2513            let y = 2.0 * (t1 + t2 + t3);
2514            out.column_mut(col).assign(&y);
2515        }
2516        out
2517    }
2518
2519    /// Apply `P̈_{ij} V = 4 (Ṁ_i ⊙ Ṁ_j) V + 2 (M ⊙ M̈_{ij}) V` columnwise.
2520    ///
2521    /// `M̈_{ij}` expands into 9 pieces `Y_α C Y_βᵀ`; `Ṁ_i ⊙ Ṁ_j` into 9 cross
2522    /// pieces `(Y_{1,α} B_{1,α} W_{1,α}ᵀ) ⊙ (Y_{2,β} B_{2,β} W_{2,β}ᵀ)`.  Both
2523    /// are evaluated via the matrix-free identities:
2524    ///
2525    ///   [(ZAZᵀ) ⊙ (YBWᵀ) v]_i   = rowwise_bilinear(Y, B · (Wᵀdiag(v)Z) · A, Z)_i,
2526    ///   [(YBWᵀ) ⊙ (Y'B'W'ᵀ) v]_i= rowwise_bilinear(Y, B · (Wᵀdiag(v)W') · B'ᵀ, Y')_i,
2527    ///
2528    /// with S := row-wise reducedweighted Gram.
2529    pub(crate) fn apply_p_ddot_ij(
2530        &self,
2531        x_r: &Array2<f64>,
2532        x_ri: &Array2<f64>,
2533        x_rj: &Array2<f64>,
2534        x_rij: &Array2<f64>,
2535        k: &Array2<f64>,
2536        dot_k_i: &Array2<f64>,
2537        dot_k_j: &Array2<f64>,
2538        k_ddot: &Array2<f64>,
2539        x_tau_tau_is_some: bool,
2540        mat: &Array2<f64>,
2541    ) -> Array2<f64> {
2542        let n = self.x_dense.nrows();
2543        let m = mat.ncols();
2544        if mat.nrows() != n || m == 0 {
2545            return Array2::<f64>::zeros(mat.raw_dim());
2546        }
2547        let mut out = Array2::<f64>::zeros((n, m));
2548        for col in 0..m {
2549            let v = mat.column(col).to_owned();
2550            // Shared reducedweighted Grams for this column.  Only the Grams
2551            // actually appearing in the 18 pieces below are computed.
2552            let s_zz = RemlState::reducedweighted_gram(x_r, &v); // Z'diag(v)Z
2553            let s_zj = RemlState::reduced_crossweighted_gram(x_r, x_rj, &v); // Z'diag(v)Y_j
2554            let s_iz = RemlState::reduced_crossweighted_gram(x_ri, x_r, &v); // Y_i'diag(v)Z
2555            let s_jz = RemlState::reduced_crossweighted_gram(x_rj, x_r, &v); // Y_j'diag(v)Z
2556            let s_ij = RemlState::reduced_crossweighted_gram(x_ri, x_rj, &v); // Y_i'diag(v)Y_j
2557
2558            // ── 4 (Ṁ_i ⊙ Ṁ_j) v ──
2559            // Ṁ_i has three pieces:
2560            //   P_i,a = Y_i K Zᵀ          — Y=Y_i, B=K, W=Z
2561            //   P_i,b = Z K̇_i Zᵀ         — Y=Z,   B=K̇_i, W=Z
2562            //   P_i,c = Z K Y_iᵀ          — Y=Z,   B=K,  W=Y_i
2563            // And symmetrically for Ṁ_j with (i→j).
2564            //
2565            // For each cross pair (α, β), compute
2566            //   core = B_α · (W_αᵀ diag(v) W_β) · B_βᵀ,
2567            //   y_piece = rowwise_bilinear(Y_α, core, Y_β),
2568            // then sum all 9 and scale by 4.
2569            let mut mdot_mdot = Array1::<f64>::zeros(n);
2570            // (a_i, a_j): Y_i, K, Z  ×  Y_j, K, Z  → W_α=Z, W_β=Z, S = s_zz
2571            {
2572                let core = k.dot(&s_zz).dot(&k.t());
2573                mdot_mdot = mdot_mdot + Self::rowwise_bilinear(x_ri, &core, x_rj);
2574            }
2575            // (a_i, b_j): Y_i, K, Z  ×  Z, K̇_j, Z  → S = s_zz; core = K · s_zz · K̇_jᵀ
2576            {
2577                let core = k.dot(&s_zz).dot(&dot_k_j.t());
2578                mdot_mdot = mdot_mdot + Self::rowwise_bilinear(x_ri, &core, x_r);
2579            }
2580            // (a_i, c_j): Y_i, K, Z  ×  Z, K, Y_j  → S = s_zj; core = K · s_zj · Kᵀ
2581            {
2582                let core = k.dot(&s_zj).dot(&k.t());
2583                mdot_mdot = mdot_mdot + Self::rowwise_bilinear(x_ri, &core, x_r);
2584            }
2585            // (b_i, a_j): Z, K̇_i, Z  ×  Y_j, K, Z  → S = s_zz; core = K̇_i · s_zz · Kᵀ
2586            {
2587                let core = dot_k_i.dot(&s_zz).dot(&k.t());
2588                mdot_mdot = mdot_mdot + Self::rowwise_bilinear(x_r, &core, x_rj);
2589            }
2590            // (b_i, b_j): Z, K̇_i, Z  ×  Z, K̇_j, Z  → S = s_zz; core = K̇_i · s_zz · K̇_jᵀ
2591            {
2592                let core = dot_k_i.dot(&s_zz).dot(&dot_k_j.t());
2593                mdot_mdot = mdot_mdot + Self::rowwise_bilinear(x_r, &core, x_r);
2594            }
2595            // (b_i, c_j): Z, K̇_i, Z  ×  Z, K, Y_j  → S = s_zj; core = K̇_i · s_zj · Kᵀ
2596            {
2597                let core = dot_k_i.dot(&s_zj).dot(&k.t());
2598                mdot_mdot = mdot_mdot + Self::rowwise_bilinear(x_r, &core, x_r);
2599            }
2600            // (c_i, a_j): Z, K, Y_i  ×  Y_j, K, Z  → S = Y_iᵀ diag(v) Z = s_iz;
2601            //   core = K · s_iz · Kᵀ
2602            {
2603                let core = k.dot(&s_iz).dot(&k.t());
2604                mdot_mdot = mdot_mdot + Self::rowwise_bilinear(x_r, &core, x_rj);
2605            }
2606            // (c_i, b_j): Z, K, Y_i  ×  Z, K̇_j, Z  → S = Y_iᵀ diag(v) Z = s_iz;
2607            //   core = K · s_iz · K̇_jᵀ
2608            {
2609                let core = k.dot(&s_iz).dot(&dot_k_j.t());
2610                mdot_mdot = mdot_mdot + Self::rowwise_bilinear(x_r, &core, x_r);
2611            }
2612            // (c_i, c_j): Z, K, Y_i  ×  Z, K, Y_j  → S = Y_iᵀ diag(v) Y_j = s_ij;
2613            //   core = K · s_ij · Kᵀ
2614            {
2615                let core = k.dot(&s_ij).dot(&k.t());
2616                mdot_mdot = mdot_mdot + Self::rowwise_bilinear(x_r, &core, x_r);
2617            }
2618
2619            // ── 2 (M ⊙ M̈_{ij}) v ──
2620            // Each piece has the form Y_α C W_βᵀ; M = Z K Zᵀ with A=K.
2621            // Identity:  [(ZAZᵀ) ⊙ (Y_α C W_βᵀ) v]_i
2622            //          = rowwise_bilinear(Y_α, C · (W_βᵀ diag(v) Z) · A, Z).
2623            let mut m_mddot = Array1::<f64>::zeros(n);
2624            // (a) Y_α = X_{r,ij}, C = K, W_β = X_r  → W_βᵀ diag(v) Z = s_zz
2625            if x_tau_tau_is_some {
2626                let core = k.dot(&s_zz).dot(k);
2627                m_mddot = m_mddot + Self::rowwise_bilinear(x_rij, &core, x_r);
2628            }
2629            // (b) Y_α = X_r, C = K, W_β = X_{r,ij} → W_βᵀ diag(v) Z = X_{r,ij}ᵀ diag(v) Z
2630            if x_tau_tau_is_some {
2631                let s_ijz = RemlState::reduced_crossweighted_gram(x_rij, x_r, &v);
2632                let core = k.dot(&s_ijz).dot(k);
2633                m_mddot = m_mddot + Self::rowwise_bilinear(x_r, &core, x_r);
2634            }
2635            // (c) Y_α = X_{r,i}, C = K̇_j, W_β = X_r → S = s_zz
2636            {
2637                let core = dot_k_j.dot(&s_zz).dot(k);
2638                m_mddot = m_mddot + Self::rowwise_bilinear(x_ri, &core, x_r);
2639            }
2640            // (d) Y_α = X_r, C = K̇_j, W_β = X_{r,i} → W_βᵀ diag(v) Z = s_iz
2641            {
2642                let core = dot_k_j.dot(&s_iz).dot(k);
2643                m_mddot = m_mddot + Self::rowwise_bilinear(x_r, &core, x_r);
2644            }
2645            // (e) Y_α = X_{r,j}, C = K̇_i, W_β = X_r → S = s_zz
2646            {
2647                let core = dot_k_i.dot(&s_zz).dot(k);
2648                m_mddot = m_mddot + Self::rowwise_bilinear(x_rj, &core, x_r);
2649            }
2650            // (f) Y_α = X_r, C = K̇_i, W_β = X_{r,j} → W_βᵀ diag(v) Z = s_jz
2651            {
2652                let core = dot_k_i.dot(&s_jz).dot(k);
2653                m_mddot = m_mddot + Self::rowwise_bilinear(x_r, &core, x_r);
2654            }
2655            // (g) Y_α = X_{r,i}, C = K, W_β = X_{r,j} → W_βᵀ diag(v) Z = s_jz
2656            {
2657                let core = k.dot(&s_jz).dot(k);
2658                m_mddot = m_mddot + Self::rowwise_bilinear(x_ri, &core, x_r);
2659            }
2660            // (h) Y_α = X_{r,j}, C = K, W_β = X_{r,i} → W_βᵀ diag(v) Z = s_iz
2661            {
2662                let core = k.dot(&s_iz).dot(k);
2663                m_mddot = m_mddot + Self::rowwise_bilinear(x_rj, &core, x_r);
2664            }
2665            // (i) Y_α = X_r, C = K̈_ij, W_β = X_r → S = s_zz
2666            {
2667                let core = k_ddot.dot(&s_zz).dot(k);
2668                m_mddot = m_mddot + Self::rowwise_bilinear(x_r, &core, x_r);
2669            }
2670
2671            // P̈_{ij} = ∂²(M⊙M)/∂τ_i∂τ_j = 2(Ṁ_i ⊙ Ṁ_j) + 2(M ⊙ M̈_{ij}),
2672            // with Ṁ_τ = ∂M/∂τ (NOT the pair-squared derivative).  Factor is 2,
2673            // not 4 — the earlier "4·Ṁ_i⊙Ṁ_j" was a sign-of-the-derivative
2674            // confusion between Ṁ and ∂(M⊙M)/∂τ = 2(M⊙Ṁ).
2675            let col_out = 2.0 * mdot_mdot + 2.0 * m_mddot;
2676            out.column_mut(col).assign(&col_out);
2677        }
2678        out
2679    }
2680
2681    /// Primitive B — prepare step: assemble the reduced kernel for
2682    /// D_β((H_φ)_τ|_β)[v].
2683    ///
2684    /// Consumes the existing `FirthTauPartialKernel`, the τ-drift partials
2685    /// (`deta_partial = η̇_τ = X_τ β` and `dot_i_partial = İ_τ`), the
2686    /// β-direction `FirthDirection` built from `deta = X v`, and
2687    /// `x_tau_v = X_τ v`, and returns a cached kernel carrying the mixed
2688    /// β-τ reduced quantities A_v, dh_v, D_β(İ_τ)[v], D_β(K̇_τ)[v],
2689    /// D_β(ḣ_τ)[v], and the w-chain derivatives needed by
2690    /// `d_beta_hphi_tau_partial_apply`.
2691    pub(crate) fn d_beta_hphi_tau_partial_prepare_from_partials(
2692        &self,
2693        tau_kernel: &FirthTauPartialKernel,
2694        deta_partial: &Array1<f64>,
2695        dot_i_partial: &Array2<f64>,
2696        beta_direction: &FirthDirection,
2697        x_tau_v: &Array1<f64>,
2698    ) -> FirthTauBetaPartialKernel {
2699        // D_β(İ_τ)[v] — three-piece symmetric form from the product rule on
2700        //   İ_τ = X_{r,τ}ᵀ W X_r + X_rᵀ W X_{r,τ} + X_rᵀ diag(w' ⊙ η̇_τ) X_r,
2701        // where W = diag(w(η)) is the Fisher weight (not its derivative).
2702        // The β-differential hits w (through η=Xβ) and η̇_τ (through X_τ β):
2703        //   D_β(X_{r,τ}ᵀ W X_r)[v] = X_{r,τ}ᵀ diag(w' ⊙ δη_v) X_r,
2704        //   D_β(X_rᵀ diag(w' ⊙ η̇_τ) X_r)[v]
2705        //     = X_rᵀ diag(w'' ⊙ η̇_τ ⊙ δη_v + w' ⊙ δη_{τ,v}) X_r,
2706        // where δη_v = beta_direction.deta, δη_{τ,v} = x_tau_v.
2707        // s_v := w' ⊙ δη_v (same weight the FirthDirection uses to build
2708        // g_u_reduced); b_vvec := w'' ⊙ δη_v = beta_direction.b_uvec is the
2709        // weight for the third-term product-rule piece.
2710        let s_v = &self.w1 * &beta_direction.deta;
2711        let mixed_diag_weight = &(&tau_kernel.dotw1 * &beta_direction.deta) + &(&self.w1 * x_tau_v);
2712        let cross1 =
2713            RemlState::reduced_crossweighted_gram(&tau_kernel.x_tau_reduced, &self.x_reduced, &s_v);
2714        let cross2 =
2715            RemlState::reduced_crossweighted_gram(&self.x_reduced, &tau_kernel.x_tau_reduced, &s_v);
2716        let diag_piece = RemlState::reducedweighted_gram(&self.x_reduced, &mixed_diag_weight);
2717        let d_beta_dot_i = &cross1 + &cross2 + &diag_piece;
2718
2719        // D_β(K̇_τ)[v] — direct Leibniz on K̇_τ = -K_r İ_τ K_r with
2720        //   D_β K_r[v] = -K_r I'_v K_r = -beta_direction.a_u_reduced.
2721        // Expanding yields
2722        //   D_β K̇_τ[v] = +A_v İ_τ K_r − K_r D_β(İ_τ)[v] K_r + K_r İ_τ A_v,
2723        // where A_v := beta_direction.a_u_reduced = +K_r I'_v K_r.  The
2724        // FirthDirection carries a_u with the opposite sign convention to the
2725        // derivation block's "A_v"; we keep the direction convention and
2726        // compose signs correctly here.
2727        let term_a = beta_direction
2728            .a_u_reduced
2729            .dot(dot_i_partial)
2730            .dot(&self.k_reduced);
2731        let term_b = self.k_reduced.dot(&d_beta_dot_i).dot(&self.k_reduced);
2732        let term_c = self
2733            .k_reduced
2734            .dot(dot_i_partial)
2735            .dot(&beta_direction.a_u_reduced);
2736        let d_beta_dot_k = &term_a - &term_b + &term_c;
2737
2738        // D_β(ḣ_τ)[v] — β-differential of
2739        //   ḣ_τ = 2·diag(X_{r,τ} K_r X_rᵀ) + diag(X_r K̇_τ X_rᵀ):
2740        //   D_β ḣ_τ[v]
2741        //     = 2·diag(X_{r,τ} D_β K_r[v] X_rᵀ) + diag(X_r D_β K̇_τ[v] X_rᵀ)
2742        //     = -2·diag(X_{r,τ} A_v X_rᵀ) + diag(X_r (D_β K̇_τ[v]) X_rᵀ).
2743        let cross_diag = Self::rowwise_bilinear(
2744            &tau_kernel.x_tau_reduced,
2745            &beta_direction.a_u_reduced,
2746            &self.x_reduced,
2747        );
2748        let inner_diag = RemlState::reduced_diag_gram(&self.x_reduced, &d_beta_dot_k);
2749        let d_beta_dot_h = -2.0 * &cross_diag + &inner_diag;
2750
2751        FirthTauBetaPartialKernel {
2752            x_tau_reduced: tau_kernel.x_tau_reduced.clone(),
2753            deta_partial: deta_partial.clone(),
2754            dot_h_partial: tau_kernel.dot_h_partial.clone(),
2755            dot_i_partial: dot_i_partial.clone(),
2756            dot_k_reduced: tau_kernel.dot_k_reduced.clone(),
2757            deta_v: beta_direction.deta.clone(),
2758            deta_tau_v: x_tau_v.clone(),
2759            a_v_reduced: beta_direction.a_u_reduced.clone(),
2760            dh_v: beta_direction.dh.clone(),
2761            b_vvec: beta_direction.b_uvec.clone(),
2762            d_beta_dot_k,
2763            d_beta_dot_h,
2764        }
2765    }
2766
2767    /// Apply the mixed β-τ P-action `P_{τ,v} · mat` to an n×m column block.
2768    ///
2769    /// Expansion:
2770    ///   P_{τ,v} = 2 (M_v ⊙ M_τ) + 2 (M ⊙ M_{τ,v}),
2771    ///     M_v     = X_r K̇_v X_rᵀ,  K̇_v = -A_v (A_v = a_v_reduced),
2772    ///     M_τ     = X_{r,τ} K_r X_rᵀ + X_r K_r X_{r,τ}ᵀ + X_r K̇_τ X_rᵀ,
2773    ///     M_{τ,v} = X_{r,τ} K̇_v X_rᵀ + X_r K̇_v X_{r,τ}ᵀ + X_r D_β K̇_τ[v] X_rᵀ.
2774    /// Hadamard-Gram pieces are evaluated column-wise via
2775    ///   ((Z M_A Wᵀ) ⊙ (Y M_B Xᵀ)) v row-i
2776    ///       = z_iᵀ M_A (Wᵀ diag(v) X) M_Bᵀ y_i.
2777    pub(crate) fn apply_p_tau_v_to_matrix(
2778        &self,
2779        kernel: &FirthTauBetaPartialKernel,
2780        mat: &Array2<f64>,
2781    ) -> Array2<f64> {
2782        let n = self.x_dense.nrows();
2783        if mat.nrows() != n || mat.ncols() == 0 {
2784            return Array2::<f64>::zeros(mat.raw_dim());
2785        }
2786        let z = &self.x_reduced;
2787        let z_tau = &kernel.x_tau_reduced;
2788        let k_r = &self.k_reduced;
2789        let a_v = &kernel.a_v_reduced; // = +K_r I'_v K_r  (so K̇_v = -a_v)
2790        let dot_k_tau = &kernel.dot_k_reduced; // K̇_τ = -K_r İ_τ K_r
2791        let d_beta_dot_k = &kernel.d_beta_dot_k; // D_β K̇_τ[v]
2792        let mut out = Array2::<f64>::zeros(mat.raw_dim());
2793        for col in 0..mat.ncols() {
2794            let v = mat.column(col).to_owned();
2795            let s_zz = RemlState::reducedweighted_gram(z, &v);
2796            let s_z_ztau = RemlState::reduced_crossweighted_gram(z, z_tau, &v);
2797
2798            // Piece 1: (X_r K̇_v X_rᵀ ⊙ X_{r,τ} K_r X_rᵀ) · v
2799            //   = -rowwise_bilinear(Z, a_v S_zz K_r, Z_τ).
2800            let mid_1 = a_v.dot(&s_zz).dot(k_r);
2801            let t1 = -Self::rowwise_bilinear(z, &mid_1, z_tau);
2802            // Piece 2: (X_r K̇_v X_rᵀ ⊙ X_r K_r X_{r,τ}ᵀ) · v
2803            //   = -reduced_diag_gram(Z, a_v S_z_ztau K_r).
2804            let mid_2 = a_v.dot(&s_z_ztau).dot(k_r);
2805            let t2 = -RemlState::reduced_diag_gram(z, &mid_2);
2806            // Piece 3: (X_r K̇_v X_rᵀ ⊙ X_r K̇_τ X_rᵀ) · v
2807            //   = -reduced_diag_gram(Z, a_v S_zz K̇_τ).
2808            let mid_3 = a_v.dot(&s_zz).dot(dot_k_tau);
2809            let t3 = -RemlState::reduced_diag_gram(z, &mid_3);
2810            // Piece 4: (M ⊙ X_{r,τ} K̇_v X_rᵀ) · v
2811            //   = -rowwise_bilinear(Z, K_r S_zz a_v, Z_τ).
2812            let mid_4 = k_r.dot(&s_zz).dot(a_v);
2813            let t4 = -Self::rowwise_bilinear(z, &mid_4, z_tau);
2814            // Piece 5: (M ⊙ X_r K̇_v X_{r,τ}ᵀ) · v
2815            //   = -reduced_diag_gram(Z, K_r S_z_ztau a_v).
2816            let mid_5 = k_r.dot(&s_z_ztau).dot(a_v);
2817            let t5 = -RemlState::reduced_diag_gram(z, &mid_5);
2818            // Piece 6: (M ⊙ X_r D_β K̇_τ[v] X_rᵀ) · v.
2819            let t6 = RemlState::apply_hadamard_gram(z, k_r, d_beta_dot_k, &v);
2820
2821            // P_{τ,v} = 2·(pieces 1-3) + 2·(pieces 4-6); each group contributes
2822            // with the same outer factor 2.
2823            let y = 2.0 * (t1 + t2 + t3 + t4 + t5 + t6);
2824            out.column_mut(col).assign(&y);
2825        }
2826        out
2827    }
2828
2829    pub(crate) fn d_beta_hphi_tau_partial_apply(
2830        &self,
2831        x_tau: &Array2<f64>,
2832        kernel: &FirthTauBetaPartialKernel,
2833        rhs: &Array2<f64>,
2834    ) -> Array2<f64> {
2835        let p = self.x_dense.ncols();
2836        if rhs.nrows() != p {
2837            return Array2::<f64>::zeros((p, rhs.ncols()));
2838        }
2839        if rhs.ncols() == 0 || p == 0 {
2840            return Array2::<f64>::zeros((p, rhs.ncols()));
2841        }
2842        // Matrix-free block apply of D_β((H_φ)_τ|_β)[v] evaluated on a rhs V.
2843        // Structure follows hphi_tau_partial_apply but replaces every weight
2844        // and every reduced Gram with its β-derivative in direction v:
2845        //
2846        //   (H_φ)_τ|_β (V) = 0.5 [X_τᵀ r(V) + Xᵀ r_τ(V)].
2847        //
2848        // D_β[v] leaves X, X_τ fixed and acts on r, r_τ:
2849        //   D_β((H_φ)_τ|_β)[v](V) = 0.5 [X_τᵀ D_β r(V)[v] + Xᵀ D_β r_τ(V)[v]].
2850        let etav = fast_ab(&self.x_dense, rhs);
2851        let etav_tau = fast_ab(x_tau, rhs);
2852        let deta_v = &kernel.deta_v;
2853        let deta_tau_v = &kernel.deta_tau_v;
2854        let eta_tau = &kernel.deta_partial;
2855        let dot_h = &kernel.dot_h_partial;
2856
2857        // Reuse τ-kernel weights.  dotw1 = w'' ⊙ η̇_τ, dotw2 = w''' ⊙ η̇_τ.
2858        let dotw1 = &self.w2 * eta_tau;
2859        let dotw2 = &self.w3 * eta_tau;
2860
2861        // β-derivative scaling vectors in direction v:
2862        //   c_v              = D_β(w''·h)[v]    = w'''·δη_v·h + w''·dh_v
2863        //   b_vvec           = D_β(w')[v]       = w''·δη_v   (= kernel.b_vvec)
2864        //   d_beta_dotw1_vec = D_β(w''·η̇_τ)[v]  = w'''·δη_v·η̇_τ + w''·δη_{τ,v}
2865        //   d_beta_dotw2_vec = D_β(w'''·η̇_τ)[v] = w''''·δη_v·η̇_τ + w'''·δη_{τ,v}
2866        let c_v = &(&(&self.w3 * deta_v) * &self.h_diag) + &(&self.w2 * &kernel.dh_v);
2867        let b_vvec = &kernel.b_vvec;
2868        let d_beta_dotw1_vec = &(&(&self.w3 * deta_v) * eta_tau) + &(&self.w2 * deta_tau_v);
2869        let d_beta_dotw2_vec = &(&(&self.w4 * deta_v) * eta_tau) + &(&self.w3 * deta_tau_v);
2870
2871        // Single-τ pieces (identical to hphi_tau_partial_apply).
2872        let qv = &etav * &self.w1.view().insert_axis(Axis(1));
2873        let qv_tau = &etav * &dotw1.view().insert_axis(Axis(1))
2874            + &etav_tau * &self.w1.view().insert_axis(Axis(1));
2875        let m_qv = self.apply_pbar_to_matrix(&qv);
2876        // apply_mtau_to_matrix only reads x_tau_reduced and dot_k_reduced off
2877        // the τ-kernel, but owning the full struct is cheap.
2878        let tau_kernel_view = FirthTauPartialKernel {
2879            deta_partial: eta_tau.clone(),
2880            dotw1: dotw1.clone(),
2881            dotw2: dotw2.clone(),
2882            dot_h_partial: dot_h.clone(),
2883            x_tau_reduced: kernel.x_tau_reduced.clone(),
2884            dot_i_partial: kernel.dot_i_partial.clone(),
2885            dot_k_reduced: kernel.dot_k_reduced.clone(),
2886        };
2887        let m_qv_tau =
2888            self.apply_mtau_to_matrix(&tau_kernel_view, &qv) + self.apply_pbar_to_matrix(&qv_tau);
2889
2890        // β-derivatives of the single-τ pieces:
2891        //   D_β qv     = etav · D_β w'[v]       = etav · b_vvec
2892        //   D_β qv_tau = etav · D_β dotw1[v] + etav_tau · D_β w'[v]
2893        let d_beta_qv = &etav * &b_vvec.view().insert_axis(Axis(1));
2894        let d_beta_qv_tau = &etav * &d_beta_dotw1_vec.view().insert_axis(Axis(1))
2895            + &etav_tau * &b_vvec.view().insert_axis(Axis(1));
2896
2897        //   D_β m_qv = P_v · qv + P · D_β qv
2898        let d_beta_m_qv = self.apply_p_u_to_matrix(&kernel.a_v_reduced, &qv)
2899            + self.apply_pbar_to_matrix(&d_beta_qv);
2900
2901        //   D_β m_qv_tau = P_{τ,v}·qv + P_τ·D_β qv + P_v·qv_tau + P·D_β qv_tau
2902        let d_beta_m_qv_tau = self.apply_p_tau_v_to_matrix(kernel, &qv)
2903            + self.apply_mtau_to_matrix(&tau_kernel_view, &d_beta_qv)
2904            + self.apply_p_u_to_matrix(&kernel.a_v_reduced, &qv_tau)
2905            + self.apply_pbar_to_matrix(&d_beta_qv_tau);
2906
2907        // D_β rv[v] where rv = etav·(w''·h) − w'·m_qv:
2908        //   D_β rv[v] = etav·c_v − b_vvec·m_qv − w'·D_β m_qv.
2909        let d_beta_rv = &etav * &c_v.view().insert_axis(Axis(1))
2910            - &m_qv * &b_vvec.view().insert_axis(Axis(1))
2911            - &d_beta_m_qv * &self.w1.view().insert_axis(Axis(1));
2912
2913        // D_β rv_tau[v] where
2914        //   rv_tau = etav·dotw2·h + etav_tau·w''·h + etav·w''·dot_h
2915        //            − m_qv·dotw1 − m_qv_tau·w'.
2916        //
2917        //   D_β(dotw2·h)[v]   = (w''''·δη_v·η̇_τ + w'''·δη_{τ,v})·h
2918        //                        + dotw2·dh_v,
2919        //   D_β(w''·h)[v]     = c_v,
2920        //   D_β(w''·dot_h)[v] = w'''·δη_v·dot_h + w''·D_β dot_h[v],
2921        //   D_β dotw1[v]      = d_beta_dotw1_vec,
2922        //   D_β w'[v]         = b_vvec.
2923        let d_beta_dotw2_h = &(&d_beta_dotw2_vec * &self.h_diag) + &(&dotw2 * &kernel.dh_v);
2924        let d_beta_w2_doth = &(&(&self.w3 * deta_v) * dot_h) + &(&self.w2 * &kernel.d_beta_dot_h);
2925
2926        let d_beta_rv_tau = &etav * &d_beta_dotw2_h.view().insert_axis(Axis(1))
2927            + &etav_tau * &c_v.view().insert_axis(Axis(1))
2928            + &etav * &d_beta_w2_doth.view().insert_axis(Axis(1))
2929            - &d_beta_m_qv * &dotw1.view().insert_axis(Axis(1))
2930            - &m_qv * &d_beta_dotw1_vec.view().insert_axis(Axis(1))
2931            - &d_beta_m_qv_tau * &self.w1.view().insert_axis(Axis(1))
2932            - &m_qv_tau * &b_vvec.view().insert_axis(Axis(1));
2933
2934        0.5 * (x_tau.t().dot(&d_beta_rv) + self.x_dense.t().dot(&d_beta_rv_tau))
2935    }
2936}
2937
2938#[cfg(test)]
2939mod tests {
2940    use super::*;
2941    use crate::mixture_link::logit_inverse_link_jet5;
2942    use gam_problem::StandardLink;
2943    use ndarray::{Array1, Array2, array};
2944
2945    // Operator-equivalence oracle accessors (#1575). The production inner-PIRLS
2946    // path memoizes the β-independent design factor and rebuilds the exact
2947    // state-dependent operator. These accessors are needed only by equivalence
2948    // unit tests, so they live in this `#[cfg(test)]` module rather than gating
2949    // individual production methods with `#[cfg(test)]`.
2950    impl FirthDenseOperator {
2951        pub(crate) fn pirls_hat_diag(&self) -> Array1<f64> {
2952            &self.w * &self.h_diag
2953        }
2954
2955        /// Per-observation Firth working-response shift `Δ_i = ½·(w'_i/w_i)·h_diag_i`
2956        /// (the link-general form; `w_i ≤ 0` rows get a zero shift). Matches the
2957        /// Jeffreys score `½ Σ_i w'_i h_i x_i` the outer REML differentiates.
2958        pub(crate) fn pirls_firth_score_shift(&self) -> Array1<f64> {
2959            let mut shift = Array1::<f64>::zeros(self.w.len());
2960            for i in 0..self.w.len() {
2961                let wi = self.w[i];
2962                if wi > 0.0 {
2963                    shift[i] = 0.5 * (self.w1[i] / wi) * self.h_diag[i];
2964                }
2965            }
2966            shift
2967        }
2968    }
2969
2970    pub(crate) fn build_logit_firth_dense_operator(
2971        x_dense: &Array2<f64>,
2972        eta: &Array1<f64>,
2973    ) -> Result<FirthDenseOperator, EstimationError> {
2974        FirthDenseOperator::build_with_observation_weights_impl(
2975            &InverseLink::Standard(StandardLink::Logit),
2976            x_dense,
2977            eta,
2978            None,
2979        )
2980    }
2981
2982    pub(crate) fn build_weighted_logit_firth_dense_operator(
2983        x_dense: &Array2<f64>,
2984        eta: &Array1<f64>,
2985        observation_weights: ndarray::ArrayView1<'_, f64>,
2986    ) -> Result<FirthDenseOperator, EstimationError> {
2987        FirthDenseOperator::build_with_observation_weights_impl(
2988            &InverseLink::Standard(StandardLink::Logit),
2989            x_dense,
2990            eta,
2991            Some(observation_weights),
2992        )
2993    }
2994
2995    pub(crate) fn logisticweight(eta: f64) -> f64 {
2996        logit_inverse_link_jet5(eta).d1
2997    }
2998
2999    pub(crate) fn firthphivalue(x: &Array2<f64>, beta: &Array1<f64>) -> f64 {
3000        let eta = x.dot(beta);
3001        let op = build_logit_firth_dense_operator(x, &eta).expect("firth operator");
3002        op.jeffreys_logdet()
3003    }
3004
3005    pub(crate) fn firthgradphi(x: &Array2<f64>, beta: &Array1<f64>) -> Array1<f64> {
3006        let eta = x.dot(beta);
3007        let op = build_logit_firth_dense_operator(x, &eta).expect("firth operator");
3008        op.jeffreys_beta_gradient()
3009    }
3010
3011    pub(crate) fn weighted_firthphivalue(
3012        x: &Array2<f64>,
3013        beta: &Array1<f64>,
3014        observation_weights: &Array1<f64>,
3015    ) -> f64 {
3016        let eta = x.dot(beta);
3017        let op = build_weighted_logit_firth_dense_operator(x, &eta, observation_weights.view())
3018            .expect("weighted firth operator");
3019        op.jeffreys_logdet()
3020    }
3021
3022    #[test]
3023    pub(crate) fn firth_reduced_fisher_logdet_is_finite_for_barely_pd_matrix() {
3024        let fisher = array![[16.0, 0.0], [0.0, 1e-15]];
3025        let (k_reduced, half_log_det) = RemlState::reduced_fisher_inverse_and_half_logdet(&fisher)
3026            .expect("barely positive-definite reduced fisher");
3027        let expected = 0.5 * 16.0_f64.ln();
3028
3029        assert!(
3030            half_log_det.is_finite(),
3031            "barely positive-definite reduced fisher produced non-finite half logdet: {half_log_det}"
3032        );
3033        assert!(
3034            (half_log_det - expected).abs() < 1e-12,
3035            "near-null Fisher direction should be excluded from pseudo-logdet: got {half_log_det}, expected {expected}"
3036        );
3037        assert!(
3038            k_reduced.iter().all(|value| value.is_finite()),
3039            "barely positive-definite reduced fisher produced non-finite inverse entries: {k_reduced:?}"
3040        );
3041        assert!(
3042            k_reduced[[1, 1]].abs() < f64::EPSILON,
3043            "near-null Fisher direction should be excluded from pseudo-inverse: {k_reduced:?}"
3044        );
3045    }
3046
3047    #[test]
3048    pub(crate) fn firth_logisticweight_derivatives_match_finite_difference() {
3049        // Validates op.w[i] (= jet.d1) and op.w1..w4[i] (= jet.d2..jet.d5)
3050        // against direct central finite differences of the logistic inverse
3051        // link pdf w(η) = μ(η)(1−μ(η)).
3052        //
3053        // Nested central differences amplify roundoff by 1/h per nesting
3054        // level, so a d1fd-of-d1fd-of-d2fd cannot deliver the tolerances
3055        // that 4th-order agreement requires. The principled replacement is
3056        // a direct higher-order stencil whose truncation and roundoff are
3057        // both controlled by a single step h:
3058        //
3059        //   d1  (2-pt):  (f(z+h) − f(z−h)) / (2h)                       O(h²) trunc
3060        //   d2  (3-pt):  (f(z+h) − 2f(z) + f(z−h)) / h²                 O(h²) trunc
3061        //   d3  (4-pt):  (−f(z−2h)+2f(z−h)−2f(z+h)+f(z+2h)) / (2h³)     O(h²) trunc
3062        //   d4  (5-pt):  (f(z−2h)−4f(z−h)+6f(z)−4f(z+h)+f(z+2h)) / h⁴   O(h²) trunc
3063        //
3064        // At h = 1e-2 the logistic pdf and its higher derivatives stay of
3065        // order ≤ 1, so truncation O(h²·M) ≲ 1e-4 and roundoff O(ε/h^n)
3066        // is well below any asserted tolerance through the 4th order.
3067        let x = array![
3068            [1.0, -1.1, 0.2],
3069            [1.0, -0.5, -0.6],
3070            [1.0, 0.0, 0.3],
3071            [1.0, 0.8, -0.4],
3072            [1.0, 1.2, 0.7],
3073        ];
3074        let beta = array![0.15, -0.6, 0.35];
3075        let eta = x.dot(&beta);
3076        let op = build_logit_firth_dense_operator(&x, &eta).expect("firth operator");
3077
3078        let h = 1e-2_f64;
3079        let w = |z: f64| logisticweight(z);
3080        let d1direct = |z: f64| (w(z + h) - w(z - h)) / (2.0 * h);
3081        let d2direct = |z: f64| (w(z + h) - 2.0 * w(z) + w(z - h)) / (h * h);
3082        let d3direct = |z: f64| {
3083            (-w(z - 2.0 * h) + 2.0 * w(z - h) - 2.0 * w(z + h) + w(z + 2.0 * h)) / (2.0 * h.powi(3))
3084        };
3085        let d4direct = |z: f64| {
3086            (w(z - 2.0 * h) - 4.0 * w(z - h) + 6.0 * w(z) - 4.0 * w(z + h) + w(z + 2.0 * h))
3087                / h.powi(4)
3088        };
3089        for i in 0..eta.len() {
3090            let z = eta[i];
3091            let wfd = w(z);
3092            let w1fd = d1direct(z);
3093            let w2fd = d2direct(z);
3094            let w3fd = d3direct(z);
3095            let w4fd = d4direct(z);
3096
3097            assert!((op.w[i] - wfd).abs() < 1e-12);
3098            assert_eq!(op.w1[i].signum(), w1fd.signum());
3099            assert_eq!(op.w2[i].signum(), w2fd.signum());
3100            assert_eq!(op.w3[i].signum(), w3fd.signum());
3101            assert_eq!(op.w4[i].signum(), w4fd.signum());
3102            assert!((op.w1[i] - w1fd).abs() < 1e-5);
3103            assert!((op.w2[i] - w2fd).abs() < 1e-4);
3104            assert!((op.w3[i] - w3fd).abs() < 1e-4);
3105            assert!((op.w4[i] - w4fd).abs() < 1e-3);
3106        }
3107    }
3108
3109    #[test]
3110    pub(crate) fn weighted_firth_jeffreys_gradient_matches_finite_difference() {
3111        let x = array![
3112            [1.0, -0.7, 0.3],
3113            [1.0, -0.2, -0.4],
3114            [1.0, 0.5, 0.1],
3115            [1.0, 1.1, -0.6],
3116            [1.0, 1.6, 0.8],
3117        ];
3118        let beta = array![0.2, -0.45, 0.25];
3119        let observation_weights = array![1.0, 0.5, 2.0, 1.5, 0.75];
3120        let eta = x.dot(&beta);
3121        let op = build_weighted_logit_firth_dense_operator(&x, &eta, observation_weights.view())
3122            .expect("weighted firth operator");
3123        let grad = op.jeffreys_beta_gradient();
3124        let h = 1e-6;
3125
3126        for j in 0..beta.len() {
3127            let mut beta_plus = beta.clone();
3128            beta_plus[j] += h;
3129            let mut beta_minus = beta.clone();
3130            beta_minus[j] -= h;
3131            let fd = (weighted_firthphivalue(&x, &beta_plus, &observation_weights)
3132                - weighted_firthphivalue(&x, &beta_minus, &observation_weights))
3133                / (2.0 * h);
3134            assert!(
3135                (grad[j] - fd).abs() < 1e-5,
3136                "weighted Firth gradient mismatch at {}: analytic={}, fd={}",
3137                j,
3138                grad[j],
3139                fd
3140            );
3141        }
3142    }
3143
3144    // ----------------------------------------------------------------------
3145    // Link-general (probit) finite-difference proof of the Jeffreys/Firth
3146    // Φ(β) = ½ log|I_r(β)|, its β-gradient ∂Φ/∂β, and the β-Hessian
3147    // derivative D H_φ[u] exposed via `hphi_direction`. Logit is used as a
3148    // regression guard against the historical logit-pinned build.
3149    // ----------------------------------------------------------------------
3150
3151    pub(crate) fn build_link_firth_op(
3152        link: StandardLink,
3153        x: &Array2<f64>,
3154        beta: &Array1<f64>,
3155    ) -> FirthDenseOperator {
3156        let eta = x.dot(beta);
3157        FirthDenseOperator::build_with_observation_weights_impl(
3158            &InverseLink::Standard(link),
3159            x,
3160            &eta,
3161            None,
3162        )
3163        .expect("link-general firth operator")
3164    }
3165
3166    pub(crate) fn link_firth_phi(link: StandardLink, x: &Array2<f64>, beta: &Array1<f64>) -> f64 {
3167        build_link_firth_op(link, x, beta).jeffreys_logdet()
3168    }
3169
3170    pub(crate) fn link_firth_grad(
3171        link: StandardLink,
3172        x: &Array2<f64>,
3173        beta: &Array1<f64>,
3174    ) -> Array1<f64> {
3175        build_link_firth_op(link, x, beta).jeffreys_beta_gradient()
3176    }
3177
3178    /// Central-difference Jacobian of the *analytic* Firth gradient, i.e. a
3179    /// numerical realization of the β-Hessian H_φ = ∂g/∂β. The Newton/REML
3180    /// path consumes H_φ (and its directional derivative) so this is the
3181    /// matrix the analytic curvature must reproduce.
3182    pub(crate) fn numeric_firth_hessian(
3183        link: StandardLink,
3184        x: &Array2<f64>,
3185        beta: &Array1<f64>,
3186        h: f64,
3187    ) -> Array2<f64> {
3188        let p = beta.len();
3189        let mut hess = Array2::<f64>::zeros((p, p));
3190        for j in 0..p {
3191            let mut bp = beta.clone();
3192            bp[j] += h;
3193            let mut bm = beta.clone();
3194            bm[j] -= h;
3195            let gp = link_firth_grad(link, x, &bp);
3196            let gm = link_firth_grad(link, x, &bm);
3197            let col = (&gp - &gm) / (2.0 * h);
3198            hess.column_mut(j).assign(&col);
3199        }
3200        hess
3201    }
3202
3203    /// #1575: the cached single-index second-direction path
3204    /// (`tk_second_direction_eye_cache` + `hphisecond_direction_apply_eye_cached`)
3205    /// must be BIT-IDENTICAL to the per-pair `hphisecond_direction_apply(.., &eye)`
3206    /// it replaces in the exact-Hessian TK outer loop. This locks the work-elision
3207    /// invariant: it removes redundant O(n·r²·p) reduced Hadamard-Gram applies, it
3208    /// must NOT change a single bit of the resulting Hessian contribution.
3209    #[test]
3210    fn hphisecond_eye_cached_matches_per_pair_bit_identical_1575() {
3211        // A 6×3 logit design with a few distinct η directions (mirrors the
3212        // multi-smooth penalty directions the TK loop contracts over).
3213        let x = array![
3214            [1.0, -1.10, 0.35],
3215            [1.0, -0.40, -0.65],
3216            [1.0, 0.15, 0.20],
3217            [1.0, 0.80, -0.45],
3218            [1.0, 1.25, 0.70],
3219            [1.0, -0.55, 0.95],
3220        ];
3221        let beta = array![0.20, -0.55, 0.30];
3222        let op = build_link_firth_op(StandardLink::Logit, &x, &beta);
3223        let p = x.ncols();
3224
3225        // Three β-direction δη vectors playing the role of eta_i[idx].
3226        let deta_list = [
3227            x.dot(&array![0.9, -0.3, 0.2]),
3228            x.dot(&array![-0.4, 0.7, 0.1]),
3229            x.dot(&array![0.1, 0.2, -0.8]),
3230        ];
3231        let dirs: Vec<FirthDirection> = deta_list
3232            .iter()
3233            .map(|d| op.direction_from_deta(d.clone()))
3234            .collect();
3235
3236        let eye = Array2::<f64>::eye(p);
3237        let cache = op.tk_second_direction_eye_cache(&dirs);
3238        for i in 0..dirs.len() {
3239            for j in 0..=i {
3240                let reference = op.hphisecond_direction_apply(&dirs[i], &dirs[j], &eye);
3241                let cached = op.hphisecond_direction_apply_eye_cached(&cache, &dirs, i, j);
3242                assert_eq!(
3243                    reference.dim(),
3244                    cached.dim(),
3245                    "shape mismatch at pair ({i},{j})"
3246                );
3247                for (a, b) in reference.iter().zip(cached.iter()) {
3248                    assert_eq!(
3249                        a.to_bits(),
3250                        b.to_bits(),
3251                        "cached D²H_φ[{i},{j}] is not bit-identical to per-pair: \
3252                         reference={a}, cached={b}"
3253                    );
3254                }
3255            }
3256        }
3257    }
3258
3259    /// A fixed, well-conditioned full-rank design (deterministic, no RNG).
3260    pub(crate) fn fixed_design_5x3() -> Array2<f64> {
3261        array![
3262            [1.0, -1.10, 0.35],
3263            [1.0, -0.40, -0.65],
3264            [1.0, 0.15, 0.20],
3265            [1.0, 0.80, -0.45],
3266            [1.0, 1.25, 0.70],
3267        ]
3268    }
3269
3270    #[test]
3271    pub(crate) fn link_general_logit_path_reproduces_historical_logit_build() {
3272        // Guard: the StandardLink::Logit path through the link-general builder
3273        // must be byte-identical to the historical logit-pinned operator for
3274        // Φ, the β-gradient, the PIRLS hat diagonal, and the cached weight
3275        // jets w, w'..w''''.
3276        let x = fixed_design_5x3();
3277        let beta = array![0.20, -0.55, 0.30];
3278        let eta = x.dot(&beta);
3279
3280        let historical = build_logit_firth_dense_operator(&x, &eta).expect("historical logit");
3281        let link_general = FirthDenseOperator::build_with_observation_weights_impl(
3282            &InverseLink::Standard(StandardLink::Logit),
3283            &x,
3284            &eta,
3285            None,
3286        )
3287        .expect("link-general logit");
3288
3289        assert_eq!(
3290            historical.jeffreys_logdet(),
3291            link_general.jeffreys_logdet(),
3292            "logit Φ must be bit-identical through the link-general path"
3293        );
3294        let g_hist = historical.jeffreys_beta_gradient();
3295        let g_link = link_general.jeffreys_beta_gradient();
3296        for j in 0..g_hist.len() {
3297            assert_eq!(
3298                g_hist[j], g_link[j],
3299                "logit gradient component {j} must be bit-identical"
3300            );
3301        }
3302        let hat_hist = historical.pirls_hat_diag();
3303        let hat_link = link_general.pirls_hat_diag();
3304        for i in 0..hat_hist.len() {
3305            assert_eq!(
3306                hat_hist[i], hat_link[i],
3307                "logit PIRLS hat diagonal {i} must be bit-identical"
3308            );
3309        }
3310        for i in 0..eta.len() {
3311            assert_eq!(historical.w[i], link_general.w[i]);
3312            assert_eq!(historical.w1[i], link_general.w1[i]);
3313            assert_eq!(historical.w2[i], link_general.w2[i]);
3314            assert_eq!(historical.w3[i], link_general.w3[i]);
3315            assert_eq!(historical.w4[i], link_general.w4[i]);
3316        }
3317    }
3318
3319    #[test]
3320    pub(crate) fn link_general_probit_jeffreys_gradient_matches_finite_difference() {
3321        // PROBIT correctness: ∂Φ/∂β from `jeffreys_beta_gradient` must match a
3322        // central finite difference of Φ(β) on a well-conditioned design.
3323        let x = fixed_design_5x3();
3324        let beta = array![0.10, -0.40, 0.25];
3325        let grad = link_firth_grad(StandardLink::Probit, &x, &beta);
3326        let h = 1e-6_f64;
3327        let mut max_rel = 0.0_f64;
3328        for j in 0..beta.len() {
3329            let mut bp = beta.clone();
3330            bp[j] += h;
3331            let mut bm = beta.clone();
3332            bm[j] -= h;
3333            let fd = (link_firth_phi(StandardLink::Probit, &x, &bp)
3334                - link_firth_phi(StandardLink::Probit, &x, &bm))
3335                / (2.0 * h);
3336            let denom = grad[j].abs().max(fd.abs()).max(1e-8);
3337            let rel = (grad[j] - fd).abs() / denom;
3338            max_rel = max_rel.max(rel);
3339            assert!(
3340                rel < 1e-6,
3341                "probit Firth gradient mismatch at {j}: analytic={}, fd={}, rel={:e}",
3342                grad[j],
3343                fd,
3344                rel
3345            );
3346        }
3347        assert!(
3348            max_rel < 1e-6,
3349            "probit gradient worst relative error {max_rel:e} exceeds 1e-6"
3350        );
3351    }
3352
3353    #[test]
3354    pub(crate) fn link_general_probit_hphi_direction_matches_finite_difference_of_hessian() {
3355        // PROBIT Hessian: `hphi_direction(direction_from_deta(X·u))` is the
3356        // analytic directional derivative D H_φ[u] of the β-Hessian. Verify it
3357        // against the central finite difference of the (numerically realized)
3358        // β-Hessian H_φ along u. The numeric H_φ at each shifted β is itself a
3359        // finite difference of the *analytic* gradient, so the base operand is
3360        // analytic at first order; only the directional step is differenced
3361        // here.
3362        let x = fixed_design_5x3();
3363        let beta = array![0.10, -0.40, 0.25];
3364        let p = beta.len();
3365
3366        // Probe several directions, including non-axis-aligned ones.
3367        let directions = [
3368            array![1.0, 0.0, 0.0],
3369            array![0.0, 1.0, 0.0],
3370            array![0.0, 0.0, 1.0],
3371            array![0.7, -0.5, 0.3],
3372        ];
3373
3374        let h_inner = 1e-4_f64; // step for the numeric Hessian (FD of analytic grad)
3375        let h_dir = 1e-4_f64; // step for the directional derivative of the Hessian
3376        let mut worst = 0.0_f64;
3377        for u in directions.iter() {
3378            let op = build_link_firth_op(StandardLink::Probit, &x, &beta);
3379            let deta = x.dot(u);
3380            let dir = op.direction_from_deta(deta);
3381            let analytic = op.hphi_direction(&dir);
3382
3383            let beta_plus = &beta + &(u * h_dir);
3384            let beta_minus = &beta - &(u * h_dir);
3385            let hess_plus = numeric_firth_hessian(StandardLink::Probit, &x, &beta_plus, h_inner);
3386            let hess_minus = numeric_firth_hessian(StandardLink::Probit, &x, &beta_minus, h_inner);
3387            let fd = (&hess_plus - &hess_minus) / (2.0 * h_dir);
3388
3389            let mut scale = 1e-6_f64;
3390            for r in 0..p {
3391                for c in 0..p {
3392                    scale = scale.max(analytic[[r, c]].abs()).max(fd[[r, c]].abs());
3393                }
3394            }
3395            for r in 0..p {
3396                for c in 0..p {
3397                    let rel = (analytic[[r, c]] - fd[[r, c]]).abs() / scale;
3398                    worst = worst.max(rel);
3399                    assert!(
3400                        rel < 5e-3,
3401                        "probit D H_φ[u] mismatch at ({r},{c}) for u={u:?}: analytic={}, fd={}, rel={:e}",
3402                        analytic[[r, c]],
3403                        fd[[r, c]],
3404                        rel
3405                    );
3406                }
3407            }
3408        }
3409        assert!(
3410            worst < 5e-3,
3411            "probit Hessian-derivative worst relative error {worst:e} exceeds 5e-3"
3412        );
3413    }
3414
3415    #[test]
3416    pub(crate) fn link_general_probit_jeffreys_finite_on_rank_deficient_design() {
3417        // Identifiable-subspace behavior: a rank-deficient design (column 3 =
3418        // column 1 + column 2) must yield a finite Φ = ½ log|Uᵀ W U|, a finite
3419        // gradient, and agree with the explicit reduced two-column design.
3420        let x_full = array![
3421            [1.0, -1.20, -0.20],
3422            [1.0, -0.40, 0.60],
3423            [1.0, 0.10, 1.10],
3424            [1.0, 0.70, 1.70],
3425            [1.0, 1.30, 2.30],
3426        ];
3427        let x_reduced = array![
3428            [1.0, -1.20],
3429            [1.0, -0.40],
3430            [1.0, 0.10],
3431            [1.0, 0.70],
3432            [1.0, 1.30],
3433        ];
3434        let beta_full = array![0.25, -0.50, 0.15];
3435        let beta_reduced = array![beta_full[0] + beta_full[2], beta_full[1] + beta_full[2]];
3436
3437        let phi_full = link_firth_phi(StandardLink::Probit, &x_full, &beta_full);
3438        let phi_reduced = link_firth_phi(StandardLink::Probit, &x_reduced, &beta_reduced);
3439        assert!(
3440            phi_full.is_finite(),
3441            "probit Φ on rank-deficient design must be finite, got {phi_full}"
3442        );
3443        assert!(
3444            (phi_full - phi_reduced).abs() < 1e-12,
3445            "probit reduced |Uᵀ W U| form mismatch: full={phi_full}, reduced={phi_reduced}"
3446        );
3447
3448        let op_full = build_link_firth_op(StandardLink::Probit, &x_full, &beta_full);
3449        let grad_full = op_full.jeffreys_beta_gradient();
3450        assert!(
3451            grad_full.iter().all(|v| v.is_finite()),
3452            "probit gradient on rank-deficient design must be finite: {grad_full:?}"
3453        );
3454        let hat_full = op_full.pirls_hat_diag();
3455        let hat_reduced =
3456            build_link_firth_op(StandardLink::Probit, &x_reduced, &beta_reduced).pirls_hat_diag();
3457        for i in 0..hat_full.len() {
3458            assert!(
3459                (hat_full[i] - hat_reduced[i]).abs() < 1e-12,
3460                "probit hat diagonal {i} mismatch on rank-deficient design: full={}, reduced={}",
3461                hat_full[i],
3462                hat_reduced[i]
3463            );
3464        }
3465    }
3466
3467    #[test]
3468    pub(crate) fn rank_deficient_and_explicit_reduced_designs_share_same_jeffreys_objective() {
3469        // Column 3 is exactly column 1 + column 2, so the original design is
3470        // rank-deficient but its identifiable subspace is represented exactly by
3471        // the explicit two-column reduced design below.
3472        let x_full = array![
3473            [1.0, -1.2, -0.2],
3474            [1.0, -0.4, 0.6],
3475            [1.0, 0.1, 1.1],
3476            [1.0, 0.7, 1.7],
3477            [1.0, 1.3, 2.3],
3478        ];
3479        let x_reduced = array![[1.0, -1.2], [1.0, -0.4], [1.0, 0.1], [1.0, 0.7], [1.0, 1.3],];
3480        let beta_full: ndarray::Array1<f64> = array![0.25, -0.5, 0.15];
3481        let beta_reduced = array![beta_full[0] + beta_full[2], beta_full[1] + beta_full[2]];
3482        let eta_full = x_full.dot(&beta_full);
3483        let eta_reduced = x_reduced.dot(&beta_reduced);
3484        let observation_weights = array![1.0, 0.5, 1.75, 0.9, 1.2];
3485
3486        for i in 0..eta_full.len() {
3487            assert!(
3488                (eta_full[i] - eta_reduced[i]).abs() < 1e-12,
3489                "eta mismatch at row {i}: full={} reduced={}",
3490                eta_full[i],
3491                eta_reduced[i]
3492            );
3493        }
3494
3495        let op_full = build_weighted_logit_firth_dense_operator(
3496            &x_full,
3497            &eta_full,
3498            observation_weights.view(),
3499        )
3500        .expect("full firth operator");
3501        let op_reduced = build_weighted_logit_firth_dense_operator(
3502            &x_reduced,
3503            &eta_reduced,
3504            observation_weights.view(),
3505        )
3506        .expect("reduced firth operator");
3507
3508        assert!(
3509            (op_full.jeffreys_logdet() - op_reduced.jeffreys_logdet()).abs() < 1e-12,
3510            "Jeffreys logdet mismatch between rank-deficient full design and its explicit reduced identifiable basis: full={} reduced={}",
3511            op_full.jeffreys_logdet(),
3512            op_reduced.jeffreys_logdet()
3513        );
3514
3515        let hat_full = op_full.pirls_hat_diag();
3516        let hat_reduced = op_reduced.pirls_hat_diag();
3517        for i in 0..hat_full.len() {
3518            assert!(
3519                (hat_full[i] - hat_reduced[i]).abs() < 1e-12,
3520                "PIRLS hat-diagonal mismatch at row {i}: full={} reduced={}",
3521                hat_full[i],
3522                hat_reduced[i]
3523            );
3524        }
3525    }
3526
3527    #[test]
3528    pub(crate) fn full_rank_reparameterizations_share_same_jeffreys_objective() {
3529        let x = array![[1.0, -1.2], [1.0, -0.4], [1.0, 0.1], [1.0, 0.7], [1.0, 1.3],];
3530        let basis = array![[1.4, -0.3], [0.6, 1.1]];
3531        let x_reparameterized = x.dot(&basis);
3532        let beta = array![0.25, -0.5];
3533        let basis_det: f64 = basis[[0, 0]] * basis[[1, 1]] - basis[[0, 1]] * basis[[1, 0]];
3534        assert!(
3535            basis_det.abs() > 1e-12,
3536            "basis transform must be invertible"
3537        );
3538        let basis_inv = array![
3539            [basis[[1, 1]] / basis_det, -basis[[0, 1]] / basis_det],
3540            [-basis[[1, 0]] / basis_det, basis[[0, 0]] / basis_det],
3541        ];
3542        let beta_reparameterized = basis_inv.dot(&beta);
3543        let eta = x.dot(&beta);
3544        let eta_reparameterized = x_reparameterized.dot(&beta_reparameterized);
3545        let observation_weights = array![1.0, 0.5, 1.75, 0.9, 1.2];
3546
3547        for i in 0..eta.len() {
3548            assert!(
3549                (eta[i] - eta_reparameterized[i]).abs() < 1e-12,
3550                "eta mismatch at row {i}: original={} reparameterized={}",
3551                eta[i],
3552                eta_reparameterized[i]
3553            );
3554        }
3555
3556        let op = build_weighted_logit_firth_dense_operator(&x, &eta, observation_weights.view())
3557            .expect("original firth operator");
3558        let op_reparameterized = build_weighted_logit_firth_dense_operator(
3559            &x_reparameterized,
3560            &eta_reparameterized,
3561            observation_weights.view(),
3562        )
3563        .expect("reparameterized firth operator");
3564
3565        assert!(
3566            (op.jeffreys_logdet() - op_reparameterized.jeffreys_logdet()).abs() < 1e-12,
3567            "Jeffreys logdet mismatch under invertible reparameterization: original={} reparameterized={}",
3568            op.jeffreys_logdet(),
3569            op_reparameterized.jeffreys_logdet()
3570        );
3571
3572        let hat = op.pirls_hat_diag();
3573        let hat_reparameterized = op_reparameterized.pirls_hat_diag();
3574        for i in 0..hat.len() {
3575            assert!(
3576                (hat[i] - hat_reparameterized[i]).abs() < 1e-12,
3577                "PIRLS hat-diagonal mismatch at row {i}: original={} reparameterized={}",
3578                hat[i],
3579                hat_reparameterized[i]
3580            );
3581        }
3582    }
3583
3584    #[test]
3585    pub(crate) fn full_rank_identifiable_basis_diagonalizes_design_metric() {
3586        let x = array![[1.0, -1.2], [1.0, -0.4], [1.0, 0.1], [1.0, 0.7], [1.0, 1.3],];
3587        let beta = array![0.25, -0.5];
3588        let eta = x.dot(&beta);
3589        let observation_weights = array![1.0, 0.5, 1.75, 0.9, 1.2];
3590        let op = build_weighted_logit_firth_dense_operator(&x, &eta, observation_weights.view())
3591            .expect("firth operator");
3592
3593        let reduced_metric = fast_atb(&op.x_reduced, &op.x_reduced);
3594        for i in 0..reduced_metric.nrows() {
3595            for j in 0..reduced_metric.ncols() {
3596                if i == j {
3597                    continue;
3598                }
3599                assert!(
3600                    reduced_metric[[i, j]].abs() < 1e-10,
3601                    "full-rank identifiable basis should diagonalize X_r'X_r: metric[{i},{j}]={}",
3602                    reduced_metric[[i, j]]
3603                );
3604            }
3605        }
3606    }
3607
3608    #[test]
3609    pub(crate) fn firth_mixedsecond_direction_apply_is_symmetric_in_direction_order() {
3610        let x = array![
3611            [1.0, -1.0, 0.2],
3612            [1.0, -0.6, -0.3],
3613            [1.0, -0.1, 0.5],
3614            [1.0, 0.3, -0.7],
3615            [1.0, 0.8, 0.1],
3616            [1.0, 1.2, -0.4],
3617        ];
3618        let beta = array![0.1, -0.25, 0.2];
3619        let eta = x.dot(&beta);
3620        let op = build_logit_firth_dense_operator(&x, &eta).expect("firth operator");
3621
3622        let u = array![0.3, -0.2, 0.4];
3623        let v = array![-0.5, 0.1, 0.25];
3624        let du = op.direction_from_deta(x.dot(&u));
3625        let dv = op.direction_from_deta(x.dot(&v));
3626
3627        let eye = Array2::<f64>::eye(x.ncols());
3628        let uv = op.hphisecond_direction_apply(&du, &dv, &eye);
3629        let vu = op.hphisecond_direction_apply(&dv, &du, &eye);
3630
3631        for i in 0..uv.nrows() {
3632            for j in 0..uv.ncols() {
3633                let a = uv[[i, j]];
3634                let b = vu[[i, j]];
3635                assert_eq!(
3636                    a.signum(),
3637                    b.signum(),
3638                    "mixed direction sign mismatch at ({i},{j}): uv={a} vu={b}"
3639                );
3640                assert!(
3641                    (a - b).abs() < 2e-7,
3642                    "mixed direction mismatch at ({i},{j}): uv={a} vu={b}"
3643                );
3644            }
3645        }
3646    }
3647
3648    #[test]
3649    pub(crate) fn firth_direction_matrix_form_matches_apply_identity_form() {
3650        let x = array![
3651            [1.0, -1.1, 0.2],
3652            [1.0, -0.6, -0.3],
3653            [1.0, -0.1, 0.5],
3654            [1.0, 0.3, -0.7],
3655            [1.0, 0.8, 0.1],
3656            [1.0, 1.2, -0.4],
3657        ];
3658        let beta = array![0.08, -0.22, 0.27];
3659        let eta = x.dot(&beta);
3660        let op = build_logit_firth_dense_operator(&x, &eta).expect("firth operator");
3661        let u = Array1::from_vec(vec![0.25, -0.4, 0.35]);
3662        let dir = op.direction_from_deta(x.dot(&u));
3663
3664        let p = x.ncols();
3665        let eye = Array2::<f64>::eye(p);
3666        let mut via_apply = op.hphi_direction_apply(&dir, &eye);
3667        for i in 0..p {
3668            for j in 0..i {
3669                let sym = 0.5 * (via_apply[[i, j]] + via_apply[[j, i]]);
3670                via_apply[[i, j]] = sym;
3671                via_apply[[j, i]] = sym;
3672            }
3673        }
3674        let direct = op.hphi_direction(&dir);
3675        let diff = &direct - &via_apply;
3676        let err = diff.iter().map(|v| v * v).sum::<f64>().sqrt();
3677        assert!(err < 1e-10, "direction/apply mismatch: {err:e}");
3678    }
3679
3680    #[test]
3681    pub(crate) fn firthphi_tau_partial_matches_finite_difference_logdet() {
3682        let x = array![
3683            [1.0, -1.0, 0.2],
3684            [1.0, -0.6, -0.3],
3685            [1.0, -0.1, 0.5],
3686            [1.0, 0.3, -0.7],
3687            [1.0, 0.8, 0.1],
3688            [1.0, 1.2, -0.4],
3689        ];
3690        let x_tau = array![
3691            [0.0, 0.15, -0.05],
3692            [0.0, -0.10, 0.02],
3693            [0.0, 0.08, 0.04],
3694            [0.0, -0.06, -0.03],
3695            [0.0, 0.05, 0.01],
3696            [0.0, -0.12, 0.06],
3697        ];
3698        let beta = array![0.1, -0.25, 0.2];
3699        let eta = x.dot(&beta);
3700        let op = build_logit_firth_dense_operator(&x, &eta).expect("firth operator");
3701        let analytic = op.exact_tau_kernel(&x_tau, &beta, false).phi_tau_partial;
3702
3703        let h = 1e-6;
3704        let x_plus = &x + &(h * &x_tau);
3705        let x_minus = &x - &(h * &x_tau);
3706        let fd = (firthphivalue(&x_plus, &beta) - firthphivalue(&x_minus, &beta)) / (2.0 * h);
3707
3708        assert!(
3709            (analytic - fd).abs() < 1e-6,
3710            "Phi_tau mismatch: analytic={analytic:.12e}, fd={fd:.12e}"
3711        );
3712    }
3713
3714    #[test]
3715    pub(crate) fn firth_gphi_tau_matches_finite_differencegradphi() {
3716        let x = array![
3717            [1.0, -1.0, 0.2],
3718            [1.0, -0.6, -0.3],
3719            [1.0, -0.1, 0.5],
3720            [1.0, 0.3, -0.7],
3721            [1.0, 0.8, 0.1],
3722            [1.0, 1.2, -0.4],
3723        ];
3724        let x_tau = array![
3725            [0.0, 0.15, -0.05],
3726            [0.0, -0.10, 0.02],
3727            [0.0, 0.08, 0.04],
3728            [0.0, -0.06, -0.03],
3729            [0.0, 0.05, 0.01],
3730            [0.0, -0.12, 0.06],
3731        ];
3732        let beta = array![0.1, -0.25, 0.2];
3733        let eta = x.dot(&beta);
3734        let op = build_logit_firth_dense_operator(&x, &eta).expect("firth operator");
3735        let analytic = op.exact_tau_kernel(&x_tau, &beta, false).gphi_tau;
3736
3737        let h = 1e-6;
3738        let x_plus = &x + &(h * &x_tau);
3739        let x_minus = &x - &(h * &x_tau);
3740        let fd = (firthgradphi(&x_plus, &beta) - firthgradphi(&x_minus, &beta)) / (2.0 * h);
3741
3742        let err = (&analytic - &fd).iter().map(|v| v * v).sum::<f64>().sqrt();
3743        assert!(
3744            err < 1e-6,
3745            "gphi_tau mismatch: analytic={analytic:?}, fd={fd:?}, err={err:e}"
3746        );
3747    }
3748
3749    /// Verify pair.a scalar (`phi_tau_tau_partial`) by central-FD'ing the
3750    /// single-τ scalar `phi_tau_partial` along τ_j at fixed β.
3751    /// Identity: ∂/∂τ_j [Φ_{τ_i}|β] = Φ_{τ_iτ_j}|β.
3752    /// Tolerance 1e-7 relative.
3753    #[test]
3754    pub(crate) fn firthphi_tau_tau_pair_scalar_matches_finite_difference() {
3755        let x = array![
3756            [1.0, -1.0, 0.2],
3757            [1.0, -0.6, -0.3],
3758            [1.0, -0.1, 0.5],
3759            [1.0, 0.3, -0.7],
3760            [1.0, 0.8, 0.1],
3761            [1.0, 1.2, -0.4],
3762        ];
3763        let x_tau_i = array![
3764            [0.0, 0.15, -0.05],
3765            [0.0, -0.10, 0.02],
3766            [0.0, 0.08, 0.04],
3767            [0.0, -0.06, -0.03],
3768            [0.0, 0.05, 0.01],
3769            [0.0, -0.12, 0.06],
3770        ];
3771        let x_tau_j = array![
3772            [0.0, -0.04, 0.11],
3773            [0.0, 0.09, -0.02],
3774            [0.0, -0.06, 0.07],
3775            [0.0, 0.10, -0.05],
3776            [0.0, -0.03, 0.08],
3777            [0.0, 0.07, -0.09],
3778        ];
3779        let beta = array![0.1, -0.25, 0.2];
3780        let eta = x.dot(&beta);
3781        let op = build_logit_firth_dense_operator(&x, &eta).expect("firth operator");
3782
3783        let analytic = op
3784            .exact_tau_tau_kernel(&x_tau_i, &x_tau_j, None, &beta, false)
3785            .phi_tau_tau_partial;
3786
3787        let h = 1e-5_f64;
3788        let eval_phi_tau_i = |x_eval: &Array2<f64>| -> f64 {
3789            let eta_e = x_eval.dot(&beta);
3790            let op_e = build_logit_firth_dense_operator(x_eval, &eta_e).expect("perturbed op");
3791            op_e.exact_tau_kernel(&x_tau_i, &beta, false)
3792                .phi_tau_partial
3793        };
3794        let x_plus = &x + &(h * &x_tau_j);
3795        let x_minus = &x - &(h * &x_tau_j);
3796        let fd = (eval_phi_tau_i(&x_plus) - eval_phi_tau_i(&x_minus)) / (2.0 * h);
3797
3798        let rel = (analytic - fd).abs() / fd.abs().max(1.0);
3799        assert!(
3800            rel < 1e-7,
3801            "pair.a scalar mismatch: analytic={analytic:.6e}, fd={fd:.6e}, rel={rel:.3e}"
3802        );
3803    }
3804
3805    /// Verify pair.g p-vector (`gphi_tau_tau`) by central-FD'ing the single-τ
3806    /// `gphi_tau` along τ_j at fixed β.
3807    /// Identity: ∂/∂τ_j [(gΦ)_{τ_i}|β] = (gΦ)_{τ_iτ_j}|β.
3808    /// Tolerance 1e-7 relative max-abs.
3809    #[test]
3810    pub(crate) fn firthphi_tau_tau_pair_g_vector_matches_finite_difference() {
3811        let x = array![
3812            [1.0, -1.0, 0.2],
3813            [1.0, -0.6, -0.3],
3814            [1.0, -0.1, 0.5],
3815            [1.0, 0.3, -0.7],
3816            [1.0, 0.8, 0.1],
3817            [1.0, 1.2, -0.4],
3818        ];
3819        let x_tau_i = array![
3820            [0.0, 0.15, -0.05],
3821            [0.0, -0.10, 0.02],
3822            [0.0, 0.08, 0.04],
3823            [0.0, -0.06, -0.03],
3824            [0.0, 0.05, 0.01],
3825            [0.0, -0.12, 0.06],
3826        ];
3827        let x_tau_j = array![
3828            [0.0, -0.04, 0.11],
3829            [0.0, 0.09, -0.02],
3830            [0.0, -0.06, 0.07],
3831            [0.0, 0.10, -0.05],
3832            [0.0, -0.03, 0.08],
3833            [0.0, 0.07, -0.09],
3834        ];
3835        let beta = array![0.1, -0.25, 0.2];
3836        let eta = x.dot(&beta);
3837        let op = build_logit_firth_dense_operator(&x, &eta).expect("firth operator");
3838
3839        let analytic = op
3840            .exact_tau_tau_kernel(&x_tau_i, &x_tau_j, None, &beta, false)
3841            .gphi_tau_tau;
3842
3843        let h = 1e-5_f64;
3844        let eval_gphi_tau_i = |x_eval: &Array2<f64>| -> Array1<f64> {
3845            let eta_e = x_eval.dot(&beta);
3846            let op_e = build_logit_firth_dense_operator(x_eval, &eta_e).expect("perturbed op");
3847            op_e.exact_tau_kernel(&x_tau_i, &beta, false).gphi_tau
3848        };
3849        let x_plus = &x + &(h * &x_tau_j);
3850        let x_minus = &x - &(h * &x_tau_j);
3851        let fd = (&eval_gphi_tau_i(&x_plus) - &eval_gphi_tau_i(&x_minus)) / (2.0 * h);
3852
3853        let scale = analytic
3854            .iter()
3855            .chain(fd.iter())
3856            .map(|v| v.abs())
3857            .fold(0.0_f64, f64::max)
3858            .max(1.0);
3859        let err_max = (&analytic - &fd)
3860            .iter()
3861            .map(|v| v.abs())
3862            .fold(0.0_f64, f64::max);
3863        let rel = err_max / scale;
3864        assert!(
3865            rel < 1e-7,
3866            "pair.g p-vector mismatch: rel={rel:.3e}\nanalytic={analytic:?}\nfd={fd:?}"
3867        );
3868    }
3869
3870    /// Verify the Primitive A body (`hphi_tau_tau_partial_apply`) against a
3871    /// finite-difference reference of the single-τ Primitive (
3872    /// `hphi_tau_partial_apply`).
3873    ///
3874    /// Identity under test:
3875    ///     ∂/∂τ_j  { (H_φ)_τ_i |_β · V }   =   ∂²H_φ/∂τ_i ∂τ_j |_β · V.
3876    ///
3877    /// Central-difference reference:
3878    ///   1. Evaluate the single-τ primitive at x, and at x ± h·X_τ_j
3879    ///      — rebuild the FirthDenseOperator (with fresh identifiable Q)
3880    ///      at each perturbed design; H_φ applied to a p-space rhs is
3881    ///      basis-invariant in unreduced β-coords, so Q rotation does not
3882    ///      contaminate the comparison.
3883    ///   2. FD_{i,j} = (T_{plus} − T_{minus}) / (2h) with T = hphi_tau_i_apply(V).
3884    ///   3. Contract both (i,j) and (j,i) directions and verify symmetry
3885    ///      of the analytic as a cross-check.
3886    ///
3887    /// Tolerance: 1e-7 relative max-abs (h chosen to balance truncation
3888    /// error at ~h² and evaluator roundoff at ~ε/h).
3889    #[test]
3890    pub(crate) fn firthphi_tau_tau_partial_matches_finite_difference() {
3891        let x = array![
3892            [1.0, -1.0, 0.2],
3893            [1.0, -0.6, -0.3],
3894            [1.0, -0.1, 0.5],
3895            [1.0, 0.3, -0.7],
3896            [1.0, 0.8, 0.1],
3897            [1.0, 1.2, -0.4],
3898        ];
3899        let x_tau_i = array![
3900            [0.0, 0.15, -0.05],
3901            [0.0, -0.10, 0.02],
3902            [0.0, 0.08, 0.04],
3903            [0.0, -0.06, -0.03],
3904            [0.0, 0.05, 0.01],
3905            [0.0, -0.12, 0.06],
3906        ];
3907        let x_tau_j = array![
3908            [0.0, -0.04, 0.11],
3909            [0.0, 0.09, -0.02],
3910            [0.0, -0.06, 0.07],
3911            [0.0, 0.10, -0.05],
3912            [0.0, -0.03, 0.08],
3913            [0.0, 0.07, -0.09],
3914        ];
3915        let beta = array![0.1, -0.25, 0.2];
3916        let eta = x.dot(&beta);
3917        let op = build_logit_firth_dense_operator(&x, &eta).expect("firth operator");
3918        let p = x.ncols();
3919
3920        // Reproducible small rhs block (p × m).
3921        let m = 3usize;
3922        let mut rhs = Array2::<f64>::zeros((p, m));
3923        let vals = [0.21, -0.44, 0.17, 0.38, 0.05, -0.22, -0.11, 0.27, 0.31];
3924        for r in 0..p {
3925            for c in 0..m {
3926                rhs[[r, c]] = vals[(r * m + c) % vals.len()];
3927            }
3928        }
3929
3930        // ── Analytic τ×τ pair apply at base design (x_tau_tau = None,
3931        //    deta_ij = None, i.e. design is linear in τ).
3932        let x_tau_i_reduced = op.reduce_explicit_design(&x_tau_i);
3933        let x_tau_j_reduced = op.reduce_explicit_design(&x_tau_j);
3934        let deta_i = x_tau_i.dot(&beta);
3935        let deta_j = x_tau_j.dot(&beta);
3936        let (dot_i_i, dot_h_i) = op.dot_i_and_h_from_reduced(&x_tau_i_reduced, &deta_i);
3937        let (dot_i_j, dot_h_j) = op.dot_i_and_h_from_reduced(&x_tau_j_reduced, &deta_j);
3938
3939        let kernel_ij = op.hphi_tau_tau_partial_prepare_from_partials(
3940            x_tau_i_reduced.clone(),
3941            x_tau_j_reduced.clone(),
3942            &deta_i,
3943            &deta_j,
3944            dot_h_i.clone(),
3945            dot_h_j.clone(),
3946            dot_i_i.clone(),
3947            dot_i_j.clone(),
3948            None,
3949            None,
3950        );
3951        let kernel_ji = op.hphi_tau_tau_partial_prepare_from_partials(
3952            x_tau_j_reduced,
3953            x_tau_i_reduced,
3954            &deta_j,
3955            &deta_i,
3956            dot_h_j,
3957            dot_h_i,
3958            dot_i_j,
3959            dot_i_i,
3960            None,
3961            None,
3962        );
3963        let analytic_ij = op.hphi_tau_tau_partial_apply(&x_tau_i, &x_tau_j, &kernel_ij, &rhs);
3964        let analytic_ji = op.hphi_tau_tau_partial_apply(&x_tau_j, &x_tau_i, &kernel_ji, &rhs);
3965
3966        // Symmetry cross-check (Clairaut): ∂²H/∂τ_i∂τ_j = ∂²H/∂τ_j∂τ_i.
3967        let sym_diff: f64 = (&analytic_ij - &analytic_ji)
3968            .iter()
3969            .map(|v| v.abs())
3970            .fold(0.0_f64, f64::max);
3971        let sym_scale: f64 = analytic_ij
3972            .iter()
3973            .chain(analytic_ji.iter())
3974            .map(|v| v.abs())
3975            .fold(0.0_f64, f64::max)
3976            .max(1.0);
3977        assert!(
3978            sym_diff / sym_scale < 1e-10,
3979            "τ×τ primitive not symmetric in direction order: sym_diff={sym_diff:.3e}"
3980        );
3981
3982        // ── FD reference: central difference of single-τ primitive in
3983        //    τ_j direction, evaluated along τ_i.
3984        let h = 1e-5_f64;
3985        let fd_block = |x_eval: &Array2<f64>| -> Array2<f64> {
3986            let eta_e = x_eval.dot(&beta);
3987            let op_e =
3988                build_logit_firth_dense_operator(x_eval, &eta_e).expect("perturbed firth operator");
3989            let x_tau_i_r = op_e.reduce_explicit_design(&x_tau_i);
3990            let deta_i_e = x_tau_i.dot(&beta);
3991            let (dot_i_i_e, dot_h_i_e) = op_e.dot_i_and_h_from_reduced(&x_tau_i_r, &deta_i_e);
3992            let kernel_i_e = op_e
3993                .hphi_tau_partial_prepare_from_partials(x_tau_i_r, &deta_i_e, dot_h_i_e, dot_i_i_e);
3994            op_e.hphi_tau_partial_apply(&x_tau_i, &kernel_i_e, &rhs)
3995        };
3996        let x_plus = &x + &(h * &x_tau_j);
3997        let x_minus = &x - &(h * &x_tau_j);
3998        let fd_ij = (&fd_block(&x_plus) - &fd_block(&x_minus)) / (2.0 * h);
3999
4000        // ── Compare analytic_ij (contracted against V along τ_j→analytic's
4001        //    second index) to fd_ij (FD of T_i in τ_j direction).
4002        let rel_max_abs_diff = |a: &Array2<f64>, b: &Array2<f64>| -> f64 {
4003            let scale = a
4004                .iter()
4005                .chain(b.iter())
4006                .map(|v| v.abs())
4007                .fold(0.0_f64, f64::max)
4008                .max(1.0);
4009            let max_diff = (a - b).iter().map(|v| v.abs()).fold(0.0_f64, f64::max);
4010            max_diff / scale
4011        };
4012        let err_ij = rel_max_abs_diff(&analytic_ij, &fd_ij);
4013
4014        // Also FD the other direction and compare to analytic_ji, to
4015        // double-cover the primitive.
4016        let fd_block_j = |x_eval: &Array2<f64>| -> Array2<f64> {
4017            let eta_e = x_eval.dot(&beta);
4018            let op_e =
4019                build_logit_firth_dense_operator(x_eval, &eta_e).expect("perturbed firth operator");
4020            let x_tau_j_r = op_e.reduce_explicit_design(&x_tau_j);
4021            let deta_j_e = x_tau_j.dot(&beta);
4022            let (dot_i_j_e, dot_h_j_e) = op_e.dot_i_and_h_from_reduced(&x_tau_j_r, &deta_j_e);
4023            let kernel_j_e = op_e
4024                .hphi_tau_partial_prepare_from_partials(x_tau_j_r, &deta_j_e, dot_h_j_e, dot_i_j_e);
4025            op_e.hphi_tau_partial_apply(&x_tau_j, &kernel_j_e, &rhs)
4026        };
4027        let x_plus_i = &x + &(h * &x_tau_i);
4028        let x_minus_i = &x - &(h * &x_tau_i);
4029        let fd_ji = (&fd_block_j(&x_plus_i) - &fd_block_j(&x_minus_i)) / (2.0 * h);
4030        let err_ji = rel_max_abs_diff(&analytic_ji, &fd_ji);
4031
4032        let tol = 1e-7_f64;
4033        assert!(
4034            err_ij < tol,
4035            "∂²H_φ/∂τ_i∂τ_j apply mismatch (i,j): rel_max_abs_diff={err_ij:.3e} > {tol:.1e}\n\
4036             analytic=\n{analytic_ij:?}\n\
4037             fd=\n{fd_ij:?}"
4038        );
4039        assert!(
4040            err_ji < tol,
4041            "∂²H_φ/∂τ_j∂τ_i apply mismatch (j,i): rel_max_abs_diff={err_ji:.3e} > {tol:.1e}\n\
4042             analytic=\n{analytic_ji:?}\n\
4043             fd=\n{fd_ji:?}"
4044        );
4045    }
4046
4047    /// Verify the Primitive B body (`d_beta_hphi_tau_partial_apply`) against a
4048    /// finite-difference reference of the single-τ Primitive
4049    /// (`hphi_tau_partial_apply`).
4050    ///
4051    /// Identity under test (β held in the unreduced ambient; the design X is
4052    /// fixed so only w, η̇_τ = X_τ β, and their β-derivatives move):
4053    ///     D_β [ (H_φ)_τ|_β (β) · V ] [v]
4054    ///       = d_beta_hphi_tau_partial_apply(v, V).
4055    ///
4056    /// Central-difference reference:
4057    ///   1. Evaluate T(t) := hphi_tau_partial_apply(V) at β_t = β + t v,
4058    ///      rebuilding FirthDenseOperator at each β (so η = X β_t and the
4059    ///      w-chain are re-derived cleanly).  X is unchanged; Q is rebuilt
4060    ///      but H_φ applied to a p-space rhs is basis-invariant.
4061    ///   2. FD = (T(+h) − T(−h)) / (2h).
4062    ///   3. Tolerance 1e-7 relative max-abs (h chosen to balance truncation
4063    ///      error at ~h² and evaluator roundoff at ~ε/h).
4064    #[test]
4065    pub(crate) fn firth_d_beta_hphi_tau_partial_matches_finite_difference() {
4066        let x = array![
4067            [1.0, -1.0, 0.2],
4068            [1.0, -0.6, -0.3],
4069            [1.0, -0.1, 0.5],
4070            [1.0, 0.3, -0.7],
4071            [1.0, 0.8, 0.1],
4072            [1.0, 1.2, -0.4],
4073        ];
4074        let x_tau = array![
4075            [0.0, 0.15, -0.05],
4076            [0.0, -0.10, 0.02],
4077            [0.0, 0.08, 0.04],
4078            [0.0, -0.06, -0.03],
4079            [0.0, 0.05, 0.01],
4080            [0.0, -0.12, 0.06],
4081        ];
4082        let beta = array![0.1, -0.25, 0.2];
4083        // β-direction v for the D_β[·][v] test.
4084        let v = array![0.3, 0.2, -0.15];
4085
4086        let eta = x.dot(&beta);
4087        let op = build_logit_firth_dense_operator(&x, &eta).expect("firth operator");
4088        let p = x.ncols();
4089
4090        // Reproducible small rhs block (p × m).
4091        let m = 3usize;
4092        let mut rhs = Array2::<f64>::zeros((p, m));
4093        let vals = [0.21, -0.44, 0.17, 0.38, 0.05, -0.22, -0.11, 0.27, 0.31];
4094        for r in 0..p {
4095            for c in 0..m {
4096                rhs[[r, c]] = vals[(r * m + c) % vals.len()];
4097            }
4098        }
4099
4100        // ── Analytic apply at (x, β).
4101        let x_tau_reduced = op.reduce_explicit_design(&x_tau);
4102        let deta_partial = x_tau.dot(&beta);
4103        let (dot_i_partial, dot_h_partial) =
4104            op.dot_i_and_h_from_reduced(&x_tau_reduced, &deta_partial);
4105        let tau_kernel = op.hphi_tau_partial_prepare_from_partials(
4106            x_tau_reduced.clone(),
4107            &deta_partial,
4108            dot_h_partial.clone(),
4109            dot_i_partial.clone(),
4110        );
4111
4112        let deta_v = x.dot(&v);
4113        let direction = op.direction_from_deta(deta_v);
4114        let x_tau_v = x_tau.dot(&v);
4115        let pair_kernel = op.d_beta_hphi_tau_partial_prepare_from_partials(
4116            &tau_kernel,
4117            &deta_partial,
4118            &dot_i_partial,
4119            &direction,
4120            &x_tau_v,
4121        );
4122        let analytic = op.d_beta_hphi_tau_partial_apply(&x_tau, &pair_kernel, &rhs);
4123
4124        // ── FD reference: central difference of single-τ primitive under
4125        //    β → β ± h v.  X stays fixed; η, w, η̇_τ are re-derived.
4126        let h = 1e-5_f64;
4127        let single_tau_apply = |beta_eval: &Array1<f64>| -> Array2<f64> {
4128            let eta_e = x.dot(beta_eval);
4129            let op_e =
4130                build_logit_firth_dense_operator(&x, &eta_e).expect("perturbed firth operator");
4131            let x_tau_r = op_e.reduce_explicit_design(&x_tau);
4132            let deta_e = x_tau.dot(beta_eval);
4133            let (dot_i_e, dot_h_e) = op_e.dot_i_and_h_from_reduced(&x_tau_r, &deta_e);
4134            let ker_e =
4135                op_e.hphi_tau_partial_prepare_from_partials(x_tau_r, &deta_e, dot_h_e, dot_i_e);
4136            op_e.hphi_tau_partial_apply(&x_tau, &ker_e, &rhs)
4137        };
4138        let beta_plus = &beta + &(h * &v);
4139        let beta_minus = &beta - &(h * &v);
4140        let fd = (&single_tau_apply(&beta_plus) - &single_tau_apply(&beta_minus)) / (2.0 * h);
4141
4142        let rel_max_abs_diff = |a: &Array2<f64>, b: &Array2<f64>| -> f64 {
4143            let scale = a
4144                .iter()
4145                .chain(b.iter())
4146                .map(|v| v.abs())
4147                .fold(0.0_f64, f64::max)
4148                .max(1.0);
4149            let max_diff = (a - b).iter().map(|v| v.abs()).fold(0.0_f64, f64::max);
4150            max_diff / scale
4151        };
4152        let err = rel_max_abs_diff(&analytic, &fd);
4153
4154        let tol = 1e-7_f64;
4155        assert!(
4156            err < tol,
4157            "D_β (H_φ)_τ|_β apply mismatch: rel_max_abs_diff={err:.3e} > {tol:.1e}\n\
4158             analytic=\n{analytic:?}\n\
4159             fd=\n{fd:?}"
4160        );
4161    }
4162
4163    #[test]
4164    pub(crate) fn logisticweight_loses_positive_tail_mass() {
4165        let eta = 50.0_f64;
4166        let z = (-eta).exp();
4167        let stable = z / (1.0_f64 + z).powi(2);
4168        assert!(stable > 0.0);
4169        let got = logisticweight(eta);
4170        assert!(
4171            (got - stable).abs() < 1e-30,
4172            "Firth logisticweight should equal the stable tail formula z/(1+z)^2 at eta={eta}; got {} vs {}",
4173            got,
4174            stable
4175        );
4176    }
4177
4178    #[test]
4179    pub(crate) fn fisher_weight_jet5_logit_is_byte_identical_to_inverse_link_jet() {
4180        // The generalized Firth weight jet for the canonical logit link must
4181        // reproduce the historical `logit_inverse_link_jet5().d1..d5` path
4182        // exactly so the released logit Firth fits stay numerically unchanged.
4183        for &eta in &[
4184            -40.0, -8.0, -3.0, -1.0, -0.25, 0.0, 0.25, 1.0, 3.0, 8.0, 40.0,
4185        ] {
4186            let jet = logit_inverse_link_jet5(eta);
4187            let (w, w1, w2, w3, w4) =
4188                crate::mixture_link::fisher_weight_jet5(StandardLink::Logit, eta);
4189            assert!(
4190                w == jet.d1 && w1 == jet.d2 && w2 == jet.d3 && w3 == jet.d4 && w4 == jet.d5,
4191                "logit Fisher-weight jet must equal inverse-link jet derivatives at eta={eta}: \
4192                 got ({w}, {w1}, {w2}, {w3}, {w4}) vs ({}, {}, {}, {}, {})",
4193                jet.d1,
4194                jet.d2,
4195                jet.d3,
4196                jet.d4,
4197                jet.d5
4198            );
4199        }
4200    }
4201
4202    #[test]
4203    pub(crate) fn fisher_weight_jet5_probit_matches_finite_difference() {
4204        // Probit Bernoulli Fisher weight W(eta) = phi^2 / (Phi (1 - Phi)).
4205        // Validate the closed-form jet against central finite differences of
4206        // the reference scalar weight.
4207        fn reference_probit_weight(eta: f64) -> f64 {
4208            let p = gam_math::probability::normal_cdf(eta);
4209            let q = 1.0 - p;
4210            let phi = gam_math::probability::normal_pdf(eta);
4211            if p <= 0.0 || q <= 0.0 {
4212                return 0.0;
4213            }
4214            phi * phi / (p * q)
4215        }
4216        let h = 1e-4_f64;
4217        for &eta in &[-3.0, -1.5, -0.5, 0.0, 0.3, 1.5, 3.0] {
4218            let (w, w1, w2, _w3, _w4) =
4219                crate::mixture_link::fisher_weight_jet5(StandardLink::Probit, eta);
4220            let ref_w = reference_probit_weight(eta);
4221            let fd1 =
4222                (reference_probit_weight(eta + h) - reference_probit_weight(eta - h)) / (2.0 * h);
4223            let fd2 = (reference_probit_weight(eta + h) - 2.0 * reference_probit_weight(eta)
4224                + reference_probit_weight(eta - h))
4225                / (h * h);
4226            assert!(
4227                (w - ref_w).abs() < 1e-10,
4228                "probit W mismatch at eta={eta}: jet {w} vs ref {ref_w}"
4229            );
4230            assert!(
4231                (w1 - fd1).abs() < 1e-5,
4232                "probit W' mismatch at eta={eta}: jet {w1} vs fd {fd1}"
4233            );
4234            assert!(
4235                (w2 - fd2).abs() < 1e-3,
4236                "probit W'' mismatch at eta={eta}: jet {w2} vs fd {fd2}"
4237            );
4238        }
4239    }
4240
4241    #[test]
4242    pub(crate) fn fisher_weight_jet5_probit_saturates_to_zero_in_tails() {
4243        // Past the point where the denominator Phi(1-Phi) underflows to zero,
4244        // the weight and all derivatives are exactly zero (the saturated-tail
4245        // convention shared with the inverse-link jet).
4246        for &eta in &[40.0_f64, -40.0, 80.0, -80.0] {
4247            let (w, w1, w2, w3, w4) =
4248                crate::mixture_link::fisher_weight_jet5(StandardLink::Probit, eta);
4249            assert!(
4250                w == 0.0 && w1 == 0.0 && w2 == 0.0 && w3 == 0.0 && w4 == 0.0,
4251                "probit Fisher weight jet must saturate to zero at eta={eta}; got \
4252                 ({w}, {w1}, {w2}, {w3}, {w4})"
4253            );
4254        }
4255        // In the moderate tail the denominator is still representable (the
4256        // complement is taken as Phi(-eta), not the cancellation-prone
4257        // `1 - Phi(eta)`), so the weight is a tiny strictly-positive finite
4258        // number with finite derivatives. It must NOT prematurely round to zero.
4259        for &eta in &[12.0_f64, -12.0] {
4260            let (w, w1, w2, w3, w4) =
4261                crate::mixture_link::fisher_weight_jet5(StandardLink::Probit, eta);
4262            assert!(
4263                w > 0.0
4264                    && w.is_finite()
4265                    && w1.is_finite()
4266                    && w2.is_finite()
4267                    && w3.is_finite()
4268                    && w4.is_finite(),
4269                "probit Fisher weight jet must be tiny-positive and finite at eta={eta}; got \
4270                 ({w}, {w1}, {w2}, {w3}, {w4})"
4271            );
4272        }
4273    }
4274}