Skip to main content

gam_solve/gpu_kernels/
reml_trace.rs

1//! GPU Hutchinson stochastic trace estimator for the REML/LAML logdet
2//! gradient, per math team block 2 (sections 12–18 of the V100 design).
3//!
4//! Public entry point: [`evidence_derivatives_hutchinson_gpu`]. For each
5//! derivative Hessian `H_j` (`j = 1..D`) and a single penalized Hessian `H`
6//! held resident on device, returns the unbiased Hutchinson estimate of
7//!
8//! ```text
9//! t_j = tr(H^{-1} H_j)
10//! ```
11//!
12//! plus the sample standard error of each estimate, computed from `K`
13//! Rademacher probe vectors `z_k ∈ {±1}^p` whose entries are drawn from a
14//! **stateless SplitMix64 counter hash** (no cuRAND state). The math
15//! identity used on device is
16//!
17//! ```text
18//! z^T H^{-1} H_j z  =  z^T H_j w   where   H w = z
19//! ```
20//!
21//! so we factor `H` **once** with `cusolverDnDpotrf`, batch-solve `H W = Z`
22//! with **one** `cusolverDnDpotrs` of `nrhs = K`, and then evaluate the
23//! quadratic forms with a custom NVRTC reduction kernel. The REML logdet
24//! gradient is `g_j = (1/2) · mean_k(q_{j,k})`.
25//!
26//! Two assembly variants for `H_j` are supported:
27//!
28//! * **Dense** — caller passes `H_j` as a `p × p` device or host matrix.
29//!   GEMM forms `Y_j = H_j W`, then a custom reduction sums
30//!   `z_k^T y_{j,k}` per (j, k). Cost: `D` GEMMs of size `p × p × K`.
31//! * **Weighted-Gram structural** — caller provides the design `X`
32//!   (`n × p`), weight vectors `A_j` (`n`, one per derivative — the
33//!   diagonal of the design's row weights that `H_j` adds), and the
34//!   per-derivative penalty contribution `Q_pen[j,k]` if any. The kernel
35//!   forms `R_Z = X Z` and `R_W = X W` **once** via GEMM and then sums
36//!   `sum_i a_j[i] · R_Z[i,k] · R_W[i,k]` per (j, k) without ever
37//!   materialising the `p × p` `H_j` matrix. Cost: 2 GEMMs of size
38//!   `n × p × K` shared across all `D` derivatives.
39//!
40//! The structural path is the high-value route for large-scale models
41//! where `p` is hundreds and there are many derivatives.
42//!
43//! # Stateless probe RNG
44//!
45//! The probe entries are produced on device by a SplitMix64 finalizer over
46//! `(seed, probe_index k, coordinate i)`. This has three consequences:
47//!
48//! 1. No cuRAND state — the kernel is fully stateless, threads write into
49//!    `Z[i + k·p]` independently.
50//! 2. **Common random numbers**: the first `K1` probes of a run with
51//!    `K2 > K1` are bit-identical to a `K = K1` run with the same seed.
52//!    This is the property that lets the adaptive `K` schedule build on
53//!    earlier probes without re-running them, and lets CPU and GPU
54//!    implementations of Hutchinson compare estimator-by-estimator (the
55//!    same probes produce the same `q_{j,k}` to round-off).
56//! 3. Reproducibility — a probe at `(seed, k, i)` is the same call after
57//!    call regardless of how the grid was scheduled.
58//!
59//! # Gating
60//!
61//! The companion helper `should_use_gpu_hutchinson` mirrors the CPU
62//! gate (`prefers_stochastic_trace_estimation` + matching kernel +
63//! plain-SPD logdet path) and adds the GPU-specific minima from the math
64//! team's section 18:
65//!
66//! * `p ≥ 512`
67//! * `K ∈ [8, 128]`
68//! * Hessian and design held resident or about to be uploaded
69//! * The projected penalty-subspace trace is **inactive** (otherwise the
70//!   CPU path projects through the IFT kernel — that route is required
71//!   for marginal-slope ρ-saturated rows)
72
73use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
74
75use gam_gpu::gpu_error::GpuError;
76
77// ────────────────────────────────────────────────────────────────────────
78// Public types
79// ────────────────────────────────────────────────────────────────────────
80
81/// Stateless seed for the SplitMix64 Rademacher probe RNG.
82#[derive(Clone, Copy, Debug)]
83pub struct ProbeSeed(pub u64);
84
85impl Default for ProbeSeed {
86    fn default() -> Self {
87        // Matches the CPU default seed (`StochasticTraceConfig::default()`)
88        // so cross-implementation parity tests can use a shared constant.
89        Self(0xCAFE_BABE)
90    }
91}
92
93/// Description of one derivative-Hessian contribution `H_j`.
94///
95/// The estimator needs `H_j` only via the quadratic form `z^T H_j w`, so we
96/// describe `H_j` *structurally* rather than as a dense matrix. The dense
97/// case is recovered by the [`DerivativeHessian::Dense`] variant.
98#[derive(Clone, Debug)]
99pub enum DerivativeHessian<'a> {
100    /// `H_j` is a `p × p` symmetric matrix. The reducer forms `Y = H_j W`
101    /// via GEMM and then sums `z_k^T y_k`.
102    Dense(ArrayView2<'a, f64>),
103    /// `H_j = X^T diag(a_j) X + P_j`, where `a_j` is an `n`-vector of row
104    /// weights and `P_j` is an optional `p × p` direct penalty contribution
105    /// that is *added* to the structural part. The reducer evaluates
106    /// `z^T H_j w  =  sum_i a_j[i] · (X z)[i] · (X w)[i]  +  z^T P_j w`
107    /// without materialising the `p × p` `H_j`.
108    WeightedGram {
109        row_weights: ArrayView1<'a, f64>,
110        penalty_extra: Option<ArrayView2<'a, f64>>,
111    },
112}
113
114impl DerivativeHessian<'_> {
115    fn dim_p(&self, expected_p: usize, expected_n: usize) -> Result<(), GpuError> {
116        match self {
117            DerivativeHessian::Dense(matrix) => {
118                if matrix.nrows() != expected_p || matrix.ncols() != expected_p {
119                    gam_gpu::gpu_bail!(
120                        "reml_trace dense H_j: shape {:?} != ({expected_p}, {expected_p})",
121                        matrix.dim()
122                    );
123                }
124            }
125            DerivativeHessian::WeightedGram {
126                row_weights,
127                penalty_extra,
128            } => {
129                if row_weights.len() != expected_n {
130                    gam_gpu::gpu_bail!(
131                        "reml_trace structural H_j: row_weights.len()={} != n={expected_n}",
132                        row_weights.len()
133                    );
134                }
135                if let Some(p_extra) = penalty_extra
136                    && (p_extra.nrows() != expected_p || p_extra.ncols() != expected_p)
137                {
138                    gam_gpu::gpu_bail!(
139                        "reml_trace structural H_j penalty_extra: shape {:?} != ({expected_p}, {expected_p})",
140                        p_extra.dim()
141                    );
142                }
143            }
144        }
145        Ok(())
146    }
147}
148
149/// Inputs to [`evidence_derivatives_hutchinson_gpu`].
150#[derive(Clone, Debug)]
151pub struct RemlTraceHutchinsonInput<'a> {
152    /// Penalized Hessian `H` (`p × p`, SPD).
153    pub penalized_hessian: ArrayView2<'a, f64>,
154    /// Per-derivative descriptors `H_j`. `D = derivatives.len()`.
155    pub derivatives: Vec<DerivativeHessian<'a>>,
156    /// Design matrix `X` (`n × p`). Required iff any `H_j` is structural;
157    /// `None` is acceptable when **all** derivatives are dense.
158    pub design: Option<ArrayView2<'a, f64>>,
159    /// Number of probe vectors. Must be ≥ 2 (so a sample SE is defined).
160    pub probe_count: usize,
161    /// Stateless RNG seed.
162    pub seed: ProbeSeed,
163}
164
165/// Output of [`evidence_derivatives_hutchinson_gpu`].
166#[derive(Clone, Debug)]
167pub struct RemlTraceHutchinsonEvidence {
168    /// `log |H|` from the cached Cholesky factor (same value the exact GPU
169    /// path returns; reusing the factor amortises this).
170    pub logdet_hessian: f64,
171    /// REML logdet gradient `g_j = (1/2) · mean_k(q_{j,k})`, length `D`.
172    pub gradient_rho_logdet: Array1<f64>,
173    /// Standard error of the half-scaled gradient estimator
174    /// `(1/2)·mean_k(q_{j,k})`, length `D`. This is the Bessel-corrected
175    /// sample standard deviation across probes divided by `sqrt(K)`, with the
176    /// same `(1/2)` REML logdet scaling as [`Self::gradient_rho_logdet`].
177    pub gradient_rho_stderr: Array1<f64>,
178    /// `K` probes actually used (matches `input.probe_count`).
179    pub probe_count: usize,
180}
181
182// ────────────────────────────────────────────────────────────────────────
183// Gating
184// ────────────────────────────────────────────────────────────────────────
185
186/// Minimum joint-dimension at which the GPU Hutchinson path is enabled.
187pub const HUTCHINSON_GPU_MIN_P: usize = 512;
188/// Minimum and maximum probe counts the GPU path accepts (math section 18).
189pub const HUTCHINSON_GPU_MIN_K: usize = 8;
190pub const HUTCHINSON_GPU_MAX_K: usize = 128;
191
192// ────────────────────────────────────────────────────────────────────────
193// Stateless SplitMix64 Rademacher RNG (host reference; mirrors the NVRTC
194// kernel byte-for-byte so CPU and GPU produce identical probes for the
195// same `(seed, k, i)`).
196// ────────────────────────────────────────────────────────────────────────
197
198/// SplitMix64 finalizer (Sebastiano Vigna, 2015). Thin wrapper over the
199/// canonical implementation in [`gam_linalg::utils::splitmix64_hash`].
200#[inline]
201pub fn splitmix64_mix(z: u64) -> u64 {
202    gam_linalg::utils::splitmix64_hash(z)
203}
204
205/// Stateless Rademacher entry at probe index `k` (0-based), coordinate
206/// `i` (0-based), seed `s`. Returns `+1.0` or `-1.0`.
207///
208/// The mix is `splitmix64(s ⊕ k·ζ ⊕ i·γ)` for two large odd constants
209/// `ζ`, `γ`; the sign bit (bit 63 of the hash) selects the sign. The two
210/// constants are *different* from the SplitMix increment so the row and
211/// column hashes don't collide on small `(k, i)`.
212#[inline]
213pub fn rademacher_entry(seed: u64, k: u64, i: u64) -> f64 {
214    const ZETA: u64 = 0xD1B5_4A32_D192_ED03;
215    const GAMMA: u64 = 0x8CB9_2BA7_2F9D_E81F;
216    let composite = seed ^ k.wrapping_mul(ZETA) ^ i.wrapping_mul(GAMMA);
217    let h = splitmix64_mix(composite);
218    if (h >> 63) == 0 { 1.0 } else { -1.0 }
219}
220
221/// Host-side reference: fill a column-major `(p, K)` Rademacher matrix.
222/// Used by tests to verify the GPU kernel produces the same bits.
223pub fn fill_rademacher_host(seed: ProbeSeed, p: usize, k: usize, out: &mut [f64]) {
224    assert_eq!(
225        out.len(),
226        p * k,
227        "fill_rademacher_host: out buffer length {} != p*K = {}*{}",
228        out.len(),
229        p,
230        k
231    );
232    for col in 0..k {
233        for row in 0..p {
234            out[col * p + row] = rademacher_entry(seed.0, col as u64, row as u64);
235        }
236    }
237}
238
239// ────────────────────────────────────────────────────────────────────────
240// CPU reference implementation of the Hutchinson estimator
241// ────────────────────────────────────────────────────────────────────────
242//
243// This path is what runs in CPU-only builds and is also what the V100
244// parity tests check the device implementation against. It uses the same
245// stateless SplitMix probes as the kernel.
246
247/// Run the Hutchinson estimator on CPU using the exact same probe bits
248/// the device kernel uses. Returns the same evidence struct.
249pub fn evidence_derivatives_hutchinson_cpu(
250    input: &RemlTraceHutchinsonInput<'_>,
251) -> Result<RemlTraceHutchinsonEvidence, String> {
252    validate_inputs(input)?;
253    let p = input.penalized_hessian.nrows();
254    let d = input.derivatives.len();
255    let k = input.probe_count;
256
257    // Cholesky factor of H (lower).
258    let h = input.penalized_hessian.to_owned();
259    let factor = cholesky_lower(&h)?;
260    let logdet_hessian = 2.0 * (0..p).map(|i| factor[[i, i]].ln()).sum::<f64>();
261
262    // Build Z (p, k) column-major in a flat vector.
263    let mut z = vec![0.0_f64; p * k];
264    fill_rademacher_host(input.seed, p, k, &mut z);
265
266    // Solve H W = Z column by column on CPU (matches what the device
267    // does in one batched potrs call). The K columns are independent — each
268    // `solve_cholesky` reads the shared (immutable) factor and writes only its
269    // own column of `w` — so they parallelize bit-for-bit (no reduction is
270    // reordered; each w-column is produced by exactly one task with identical
271    // arithmetic). The probes are embarrassingly parallel by construction; the
272    // CRN contract lives in the stateless SplitMix fill above, untouched.
273    use rayon::prelude::*;
274    let mut w = vec![0.0_f64; p * k];
275    w.par_chunks_mut(p)
276        .zip(z.par_chunks(p))
277        .for_each(|(w_col, z_col)| {
278            let solved = solve_cholesky(&factor, z_col);
279            w_col.copy_from_slice(&solved);
280        });
281
282    // Per-derivative quadratic forms. Each `q[j*k + col]` is an independent
283    // scalar function of probe column `col` only, so we parallelize over the
284    // probe columns. This is bit-identical to the serial fill: a given q entry
285    // is computed by one task with the same per-entry arithmetic, and the
286    // downstream `reduce_mean_stderr` indexes fixed (j, col) positions — no
287    // sum is reordered across threads.
288    let mut q = vec![0.0_f64; d * k]; // row-major (d, k): q[j*k + m]
289    for (j, derivative) in input.derivatives.iter().enumerate() {
290        let q_row = &mut q[j * k..(j + 1) * k];
291        match derivative {
292            DerivativeHessian::Dense(matrix) => {
293                q_row
294                    .par_iter_mut()
295                    .zip(z.par_chunks(p).zip(w.par_chunks(p)))
296                    .for_each(|(q_jk, (z_col, w_col))| {
297                        // y = H_j w
298                        let mut y = vec![0.0_f64; p];
299                        for r in 0..p {
300                            let mut acc = 0.0_f64;
301                            for c in 0..p {
302                                acc += matrix[[r, c]] * w_col[c];
303                            }
304                            y[r] = acc;
305                        }
306                        let mut zy = 0.0_f64;
307                        for i in 0..p {
308                            zy += z_col[i] * y[i];
309                        }
310                        *q_jk = zy;
311                    });
312            }
313            DerivativeHessian::WeightedGram {
314                row_weights,
315                penalty_extra,
316            } => {
317                let design = input.design.as_ref().expect("design validated");
318                let n = design.nrows();
319                q_row
320                    .par_iter_mut()
321                    .zip(z.par_chunks(p).zip(w.par_chunks(p)))
322                    .for_each(|(q_jk, (z_col, w_col))| {
323                        // r_z = X z (length n), r_w = X w (length n)
324                        let mut acc = 0.0_f64;
325                        for row in 0..n {
326                            let mut rz = 0.0_f64;
327                            let mut rw = 0.0_f64;
328                            for col_idx in 0..p {
329                                rz += design[[row, col_idx]] * z_col[col_idx];
330                                rw += design[[row, col_idx]] * w_col[col_idx];
331                            }
332                            acc += row_weights[row] * rz * rw;
333                        }
334                        if let Some(pen) = penalty_extra {
335                            for r in 0..p {
336                                let mut row_acc = 0.0_f64;
337                                for c in 0..p {
338                                    row_acc += pen[[r, c]] * w_col[c];
339                                }
340                                acc += z_col[r] * row_acc;
341                            }
342                        }
343                        *q_jk = acc;
344                    });
345            }
346        }
347    }
348
349    let (means, stderrs) = reduce_mean_stderr(&q, d, k);
350    let mut gradient_rho_logdet = Array1::<f64>::zeros(d);
351    let mut gradient_rho_stderr = Array1::<f64>::zeros(d);
352    for j in 0..d {
353        gradient_rho_logdet[j] = 0.5 * means[j];
354        gradient_rho_stderr[j] = 0.5 * stderrs[j];
355    }
356
357    Ok(RemlTraceHutchinsonEvidence {
358        logdet_hessian,
359        gradient_rho_logdet,
360        gradient_rho_stderr,
361        probe_count: k,
362    })
363}
364
365// ────────────────────────────────────────────────────────────────────────
366// Public dispatch entry point
367// ────────────────────────────────────────────────────────────────────────
368
369/// Compute `log |H|` and the Hutchinson estimate of `(1/2) tr(H^{-1} H_j)`
370/// for every derivative. Dispatches to the device-resident path when the
371/// CUDA runtime is up and probes the GPU successfully; otherwise runs the
372/// CPU reference. Either way the probe bits are identical (stateless
373/// SplitMix), so callers see the same estimator value to round-off.
374pub fn evidence_derivatives_hutchinson_gpu(
375    input: RemlTraceHutchinsonInput<'_>,
376) -> Result<RemlTraceHutchinsonEvidence, String> {
377    validate_inputs(&input)?;
378
379    #[cfg(target_os = "linux")]
380    {
381        if gam_gpu::device_runtime::GpuRuntime::resolve(gam_gpu::global_policy())
382            .map_err(|error| error.to_string())?
383            .is_some()
384        {
385            match linux_cuda::evidence_derivatives(&input) {
386                Ok(evidence) => return Ok(evidence),
387                Err(GpuError::NoDeviceKernel { .. }) => {
388                    log::debug!(
389                        "reml-trace evidence derivatives: no device kernel in this build; \
390                         falling through to the CPU reference"
391                    );
392                }
393                Err(other) => return Err(String::from(other)),
394            }
395        }
396    }
397
398    evidence_derivatives_hutchinson_cpu(&input)
399}
400
401// ────────────────────────────────────────────────────────────────────────
402// Adaptive K (Block 2.5)
403// ────────────────────────────────────────────────────────────────────────
404
405/// Default relative-error target for the adaptive-K stopping rule.
406/// Matches `StochasticTraceConfig::default().relative_tol`.
407pub const HUTCHINSON_ADAPTIVE_REL_TOL: f64 = 0.01;
408/// Default near-zero-trace protection floor. Matches
409/// `StochasticTraceConfig::default().tau_rel`.
410pub const HUTCHINSON_ADAPTIVE_TAU_REL: f64 = 1e-8;
411
412/// Adaptive-K Hutchinson trace schedule with common random numbers (CRN).
413///
414/// Repeatedly invokes [`evidence_derivatives_hutchinson_gpu`] with probe
415/// counts `K = 16, 32, 64, 128`, stopping at the first `K` that satisfies
416/// the per-coordinate relative-SE criterion
417///
418/// ```text
419/// max_j  SE(t_j) / max(|t_j|, τ)  ≤  ε
420/// ```
421///
422/// where `SE(t_j)` is the standard error of the raw quadratic-form running
423/// mean (without the `(1/2)` REML logdet scaling) and `t_j` is the running mean. Because the SplitMix probe RNG is
424/// stateless (`(seed, k_index, i) → ±1`), the first `K_prev` probes of a
425/// `K = 2·K_prev` re-run are bit-identical to the previous batch, so each
426/// step extends the prior estimate rather than starting fresh in
427/// expectation. The implementation re-runs from scratch at each `K` for
428/// simplicity; CRN is preserved by the stateless RNG seed.
429///
430/// Returns the **raw traces** `t_j = tr(H⁻¹ H_j) = mean_k q_{j,k}`
431/// (length `D`), the `log|H|` from the cached Cholesky, and the final
432/// probe count `K` actually used. The raw traces (not the `(1/2)` REML
433/// logdet gradient) are what the outer evaluator wants — it applies the
434/// logdet-gradient half-factor itself.
435pub struct AdaptiveTraceEvidence {
436    pub logdet_hessian: f64,
437    pub traces: Array1<f64>,
438    /// Standard error of the raw trace estimator `mean_k(q_{j,k})`, i.e. the
439    /// Bessel-corrected sample standard deviation divided by `sqrt(K)`.
440    pub stderrs: Array1<f64>,
441    pub probe_count: usize,
442    pub converged: bool,
443}
444
445pub fn evidence_traces_adaptive<'a>(
446    penalized_hessian: ArrayView2<'a, f64>,
447    derivatives: Vec<DerivativeHessian<'a>>,
448    design: Option<ArrayView2<'a, f64>>,
449    seed: ProbeSeed,
450    rel_tol: f64,
451    tau_rel: f64,
452) -> Result<AdaptiveTraceEvidence, String> {
453    // Adaptive schedule per math team block 2 §16: K = 16, 32, 64, 128.
454    const SCHEDULE: [usize; 4] = [16, 32, 64, 128];
455
456    let d = derivatives.len();
457    if d == 0 {
458        return Err("evidence_traces_adaptive: derivatives is empty".to_string());
459    }
460    if !(rel_tol > 0.0) {
461        return Err(format!(
462            "evidence_traces_adaptive: rel_tol must be > 0 (got {rel_tol})"
463        ));
464    }
465    if !(tau_rel > 0.0) {
466        return Err(format!(
467            "evidence_traces_adaptive: tau_rel must be > 0 (got {tau_rel})"
468        ));
469    }
470
471    let mut last_logdet = 0.0_f64;
472    let mut last_traces = Array1::<f64>::zeros(d);
473    let mut last_stderrs = Array1::<f64>::zeros(d);
474    let mut last_k = 0_usize;
475    let mut converged = false;
476
477    for &k in &SCHEDULE {
478        let input = RemlTraceHutchinsonInput {
479            penalized_hessian,
480            derivatives: derivatives.clone(),
481            design,
482            probe_count: k,
483            seed,
484        };
485        let evidence = evidence_derivatives_hutchinson_gpu(input)?;
486        last_logdet = evidence.logdet_hessian;
487        last_k = k;
488
489        // The dispatch entry returns the **(1/2)·mean** REML logdet
490        // gradient and **(1/2)·SE**. Undo the half to recover the raw
491        // `t_j = mean_k q_{j,k}` and the standard error of the raw mean.
492        for j in 0..d {
493            last_traces[j] = 2.0 * evidence.gradient_rho_logdet[j];
494            last_stderrs[j] = 2.0 * evidence.gradient_rho_stderr[j];
495        }
496
497        // Stopping rule (math block 2 §16):
498        //   max_j  SE(t_j) / max(|t_j|, τ)  ≤  ε
499        // where `last_stderrs[j]` is already the standard error of the
500        // running mean.
501        let mut worst = 0.0_f64;
502        for j in 0..d {
503            let denom = last_traces[j].abs().max(tau_rel);
504            let r = last_stderrs[j] / denom;
505            if r > worst {
506                worst = r;
507            }
508        }
509        if worst <= rel_tol {
510            converged = true;
511            break;
512        }
513    }
514
515    Ok(AdaptiveTraceEvidence {
516        logdet_hessian: last_logdet,
517        traces: last_traces,
518        stderrs: last_stderrs,
519        probe_count: last_k,
520        converged,
521    })
522}
523
524// ────────────────────────────────────────────────────────────────────────
525// Outer logdet-gradient dispatch gate (Block 2.5)
526// ────────────────────────────────────────────────────────────────────────
527
528/// Composite gate predicate for the outer REML logdet-gradient bypass:
529/// when this returns `true`, the unified evaluator should replace its
530/// CPU stochastic-trace call with [`evidence_traces_adaptive`].
531///
532/// All five conditions must hold simultaneously:
533/// * `p ≥ 512` and `K_initial..=K_max` is `[16, 128]`
534/// * `H` is resident as a dense SPD operator (caller passes
535///   `dense_spd_h_resident = true` when `hop.as_exact_dense_spectral()`
536///   is `Some` AND the Cholesky succeeds — the latter is checked
537///   indirectly by `plain_spd_logdet`).
538/// * `plain_spd_logdet`: the operator's logdet kernel is `H⁻¹` exactly
539///   (i.e. `hop.logdet_traces_match_hinv_kernel() && hop.is_dense()`),
540///   so smooth-spectral and SCOP-warped paths are excluded.
541/// * `prefers_stochastic`: `hop.prefers_stochastic_trace_estimation()`.
542/// * `!projected_penalty_subspace_active`: the rank-deficient LAML
543///   projected kernel `U_S H_proj⁻¹ U_Sᵀ` is **not** installed.
544#[must_use]
545pub fn should_bypass_cpu_with_gpu_adaptive(
546    p: usize,
547    dense_spd_h_resident: bool,
548    plain_spd_logdet: bool,
549    prefers_stochastic: bool,
550    projected_penalty_subspace_active: bool,
551) -> bool {
552    p >= HUTCHINSON_GPU_MIN_P
553        && dense_spd_h_resident
554        && plain_spd_logdet
555        && prefers_stochastic
556        && !projected_penalty_subspace_active
557}
558
559// ────────────────────────────────────────────────────────────────────────
560// Linux/CUDA implementation
561// ────────────────────────────────────────────────────────────────────────
562
563#[cfg(target_os = "linux")]
564mod linux_cuda {
565    use super::{
566        DerivativeHessian, ProbeSeed, RemlTraceHutchinsonEvidence, RemlTraceHutchinsonInput,
567        reduce_mean_stderr,
568    };
569    use cudarc::cublas::sys::cublasOperation_t;
570    use cudarc::cublas::{CudaBlas, Gemm, GemmConfig};
571    use cudarc::cusolver::DnHandle;
572    use cudarc::driver::{CudaContext, CudaModule, CudaStream, LaunchConfig, PushKernelArg};
573    use gam_gpu::driver::to_col_major;
574    use gam_gpu::gpu_error::{GpuError, GpuResultExt};
575    use gam_gpu::solver::{
576        cholesky_logdet_from_col_major, context_and_stream, pinned_htod, potrf_in_place,
577        potrs_in_place,
578    };
579    use std::sync::Arc;
580
581    /// NVRTC source for the three custom kernels used by this path. All
582    /// arithmetic is in `double` and the layouts are column-major to match
583    /// cuBLAS/cuSOLVER conventions.
584    ///
585    /// * `fill_rademacher_splitmix(seed, p, K, Z)` — stateless ±1 fill.
586    /// * `reduce_q_dense(p, K, D, Z, Y_stack, Q)` — `Q[j,k] = z_k^T Y_j[:,k]`
587    ///   with `Y_j[:,k] = (H_j W)[:,k]`. `Y_stack` is column-major shape
588    ///   `(p, K·D)` with derivative `j` occupying columns `[j·K, (j+1)·K)`.
589    /// * `reduce_q_weighted_gram(n, K, D, RZ_stride, RZ, RW, A_stack, Q)`
590    ///   — `Q[j,k] = sum_i A[i,j] · RZ[i,k] · RW[i,k]`. Used by the
591    ///   structural path. `A_stack` is column-major `(n, D)`.
592    ///
593    /// The reductions use a per-block warp-shuffle pattern with one block
594    /// per `(j, k)` output cell and `THREADS_PER_BLOCK` threads per block.
595    pub(super) const PTX_SOURCE: &str = r#"
596extern "C" __device__ unsigned long long splitmix64_mix(unsigned long long z) {
597    z += 0x9E3779B97F4A7C15ULL;
598    unsigned long long x = z;
599    x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9ULL;
600    x = (x ^ (x >> 27)) * 0x94D049BB133111EBULL;
601    return x ^ (x >> 31);
602}
603
604extern "C" __global__ void fill_rademacher_splitmix(
605    unsigned long long seed,
606    unsigned int p,
607    unsigned int K,
608    double* __restrict__ Z)
609{
610    unsigned int i = blockIdx.x * blockDim.x + threadIdx.x;
611    unsigned int k = blockIdx.y;
612    if (i >= p || k >= K) return;
613    const unsigned long long ZETA  = 0xD1B54A32D192ED03ULL;
614    const unsigned long long GAMMA = 0x8CB92BA72F9DE81FULL;
615    unsigned long long composite =
616        seed
617        ^ (((unsigned long long)k) * ZETA)
618        ^ (((unsigned long long)i) * GAMMA);
619    unsigned long long h = splitmix64_mix(composite);
620    double v = (h >> 63) == 0 ? 1.0 : -1.0;
621    Z[(size_t)k * (size_t)p + (size_t)i] = v;
622}
623
624extern "C" __device__ double block_reduce_sum(double v) {
625    __shared__ double smem[32];
626    int lane = threadIdx.x & 31;
627    int wid  = threadIdx.x >> 5;
628    for (int off = 16; off > 0; off >>= 1) {
629        v += __shfl_down_sync(0xffffffff, v, off);
630    }
631    if (lane == 0) smem[wid] = v;
632    __syncthreads();
633    double total = 0.0;
634    int n_warps = (blockDim.x + 31) >> 5;
635    if (threadIdx.x < (unsigned)n_warps) total = smem[threadIdx.x];
636    if (wid == 0) {
637        for (int off = 16; off > 0; off >>= 1) {
638            total += __shfl_down_sync(0xffffffff, total, off);
639        }
640    }
641    return total;
642}
643
644extern "C" __global__ void reduce_q_dense(
645    unsigned int p,
646    unsigned int K,
647    unsigned int D,
648    const double* __restrict__ Z,
649    const double* __restrict__ Y_stack,
650    double* __restrict__ Q)
651{
652    unsigned int k = blockIdx.x;
653    unsigned int j = blockIdx.y;
654    if (k >= K || j >= D) return;
655    const double* z_col = Z + (size_t)k * (size_t)p;
656    const double* y_col = Y_stack + ((size_t)j * (size_t)K + (size_t)k) * (size_t)p;
657    double partial = 0.0;
658    for (unsigned int i = threadIdx.x; i < p; i += blockDim.x) {
659        partial += z_col[i] * y_col[i];
660    }
661    double total = block_reduce_sum(partial);
662    if (threadIdx.x == 0) {
663        Q[(size_t)j * (size_t)K + (size_t)k] = total;
664    }
665}
666
667extern "C" __global__ void reduce_q_weighted_gram(
668    unsigned int n,
669    unsigned int K,
670    unsigned int D,
671    const double* __restrict__ RZ,
672    const double* __restrict__ RW,
673    const double* __restrict__ A_stack,
674    double* __restrict__ Q)
675{
676    unsigned int k = blockIdx.x;
677    unsigned int j = blockIdx.y;
678    if (k >= K || j >= D) return;
679    const double* rz_col = RZ + (size_t)k * (size_t)n;
680    const double* rw_col = RW + (size_t)k * (size_t)n;
681    const double* a_col  = A_stack + (size_t)j * (size_t)n;
682    double partial = 0.0;
683    for (unsigned int i = threadIdx.x; i < n; i += blockDim.x) {
684        partial += a_col[i] * rz_col[i] * rw_col[i];
685    }
686    double total = block_reduce_sum(partial);
687    if (threadIdx.x == 0) {
688        Q[(size_t)j * (size_t)K + (size_t)k] = total;
689    }
690}
691"#;
692
693    const THREADS_PER_BLOCK: u32 = 256;
694
695    fn module(ctx: &Arc<CudaContext>) -> Result<&'static Arc<CudaModule>, GpuError> {
696        static CACHE: gam_gpu::device_cache::PtxModuleCache =
697            gam_gpu::device_cache::PtxModuleCache::new();
698        CACHE.get_or_compile(ctx, "reml_trace", PTX_SOURCE)
699    }
700
701    pub(super) fn evidence_derivatives(
702        input: &RemlTraceHutchinsonInput<'_>,
703    ) -> Result<RemlTraceHutchinsonEvidence, GpuError> {
704        let p = input.penalized_hessian.nrows();
705        let d = input.derivatives.len();
706        let k = input.probe_count;
707        let (ctx, stream) =
708            context_and_stream().map_err(|reason| GpuError::DriverCallFailed { reason })?;
709        let solver = DnHandle::new(stream.clone()).gpu_ctx("reml_trace cusolver init")?;
710        let blas = CudaBlas::new(stream.clone()).gpu_ctx("reml_trace cublas init")?;
711        let compiled = module(&ctx)?;
712        let module_handle: &Arc<CudaModule> = compiled;
713
714        // ── 1. Upload H, factor once.
715        let h_col = to_col_major(&input.penalized_hessian);
716        let mut h_dev =
717            pinned_htod(&stream, &h_col).map_err(|reason| GpuError::DriverCallFailed { reason })?;
718        potrf_in_place(&solver, &stream, p, &mut h_dev)
719            .map_err(|reason| GpuError::DriverCallFailed { reason })?;
720        let factor_col = stream
721            .clone_dtoh(&h_dev)
722            .gpu_ctx("reml_trace download factor")?;
723        let logdet_hessian = cholesky_logdet_from_col_major(&factor_col, p);
724
725        // ── 2. Allocate Z (p, K) and fill with Rademacher entries on device.
726        let total_z = p
727            .checked_mul(k)
728            .ok_or_else(|| gam_gpu::gpu_err!("reml_trace Z size overflow: p={p}, K={k}"))?;
729        let mut z_dev = stream
730            .alloc_zeros::<f64>(total_z)
731            .gpu_ctx("reml_trace alloc Z")?;
732        launch_fill_rademacher(&stream, module_handle, input.seed, p, k, &mut z_dev)?;
733
734        // ── 3. Solve H W = Z in a single batched potrs call (nrhs = K).
735        //     Copy Z into a fresh buffer first; potrs is in-place.
736        let mut w_dev = stream
737            .alloc_zeros::<f64>(total_z)
738            .gpu_ctx("reml_trace alloc W")?;
739        copy_device_slice(&stream, &z_dev, &mut w_dev)?;
740        potrs_in_place(&solver, &stream, p, k, &h_dev, &mut w_dev)
741            .map_err(|reason| GpuError::DriverCallFailed { reason })?;
742
743        // ── 4. Partition derivatives by kind.
744        let mut dense_indices: Vec<usize> = Vec::new();
745        let mut gram_indices: Vec<usize> = Vec::new();
746        for (j, deriv) in input.derivatives.iter().enumerate() {
747            match deriv {
748                DerivativeHessian::Dense(_) => dense_indices.push(j),
749                DerivativeHessian::WeightedGram { .. } => gram_indices.push(j),
750            }
751        }
752
753        let mut q_host = vec![0.0_f64; d * k];
754
755        // ── 5a. Dense path: for each dense H_j run a p×p × p×K GEMM and
756        //       reduce. We loop over j rather than stacking the H_j's
757        //       (would explode memory at large-scale-p), but the GEMMs share
758        //       the resident W buffer.
759        if !dense_indices.is_empty() {
760            for &j in &dense_indices {
761                let DerivativeHessian::Dense(matrix) = &input.derivatives[j] else {
762                    // SAFETY: dense_indices was populated in the partition loop above
763                    // with exactly the indices whose variant is DerivativeHessian::Dense.
764                    // input.derivatives is immutably borrowed for the whole function so
765                    // the slot at index j cannot have been rewritten between partition and
766                    // this read; reaching this branch can only mean a future refactor split
767                    // the partition from its consumer. The panic names the offending index.
768                    panic!(
769                        "reml_trace dense path: derivative index {j} is in dense_indices but \
770                         input.derivatives[{j}] is not DerivativeHessian::Dense — \
771                         dense_indices partition invariant violated"
772                    );
773                };
774                let hj_col = to_col_major(matrix);
775                let hj_dev = pinned_htod(&stream, &hj_col)
776                    .map_err(|reason| GpuError::DriverCallFailed { reason })?;
777                let mut y_dev = stream
778                    .alloc_zeros::<f64>(total_z)
779                    .map_err(|err| gam_gpu::gpu_err!("reml_trace alloc Y_j (j={j}): {err}"))?;
780                gemm_nn(
781                    &blas,
782                    GemmShape {
783                        m: p,
784                        n: k,
785                        k_inner: p,
786                        lda: p,
787                        ldb: p,
788                        ldc: p,
789                    },
790                    &hj_dev,
791                    &w_dev,
792                    &mut y_dev,
793                )?;
794                let mut q_j_dev = stream
795                    .alloc_zeros::<f64>(k)
796                    .gpu_ctx_with(|err| format!("reml_trace alloc Q_j (j={j}): {err}"))?;
797                launch_reduce_q_dense(
798                    &stream,
799                    module_handle,
800                    p,
801                    k,
802                    1,
803                    &z_dev,
804                    &y_dev,
805                    &mut q_j_dev,
806                )?;
807                let q_host_j = stream
808                    .clone_dtoh(&q_j_dev)
809                    .gpu_ctx_with(|err| format!("reml_trace download Q_j (j={j}): {err}"))?;
810                q_host[j * k..(j + 1) * k].copy_from_slice(&q_host_j);
811            }
812        }
813
814        // ── 5b. Structural path: form R_Z = X Z and R_W = X W **once**,
815        //       then run reduce_q_weighted_gram for each derivative.
816        if !gram_indices.is_empty() {
817            let design = input
818                .design
819                .as_ref()
820                .ok_or_else(|| GpuError::DriverCallFailed {
821                    reason: "reml_trace: structural derivative present but design=None".to_string(),
822                })?;
823            let n = design.nrows();
824            let design_col = to_col_major(design);
825            let x_dev = pinned_htod(&stream, &design_col)
826                .map_err(|reason| GpuError::DriverCallFailed { reason })?;
827            let mut rz_dev = stream
828                .alloc_zeros::<f64>(
829                    n.checked_mul(k)
830                        .ok_or_else(|| gam_gpu::gpu_err!("reml_trace RZ overflow: n={n}, K={k}"))?,
831                )
832                .gpu_ctx("reml_trace alloc RZ")?;
833            let mut rw_dev = stream
834                .alloc_zeros::<f64>(n * k)
835                .gpu_ctx("reml_trace alloc RW")?;
836            // R_Z = X Z   (n × p) · (p × K) -> (n × K)
837            gemm_nn(
838                &blas,
839                GemmShape {
840                    m: n,
841                    n: k,
842                    k_inner: p,
843                    lda: n,
844                    ldb: p,
845                    ldc: n,
846                },
847                &x_dev,
848                &z_dev,
849                &mut rz_dev,
850            )?;
851            // R_W = X W
852            gemm_nn(
853                &blas,
854                GemmShape {
855                    m: n,
856                    n: k,
857                    k_inner: p,
858                    lda: n,
859                    ldb: p,
860                    ldc: n,
861                },
862                &x_dev,
863                &w_dev,
864                &mut rw_dev,
865            )?;
866
867            // Stack the row-weight vectors into A_stack column-major (n × D_gram).
868            let d_gram = gram_indices.len();
869            let mut a_stack = Vec::<f64>::with_capacity(n * d_gram);
870            for &j in &gram_indices {
871                let DerivativeHessian::WeightedGram { row_weights, .. } = &input.derivatives[j]
872                else {
873                    // SAFETY: gram_indices was populated in the partition loop above with
874                    // exactly the indices whose variant is DerivativeHessian::WeightedGram.
875                    // input.derivatives is immutably borrowed for the whole function so the
876                    // slot at j cannot have been rewritten between partition and read; a
877                    // failure here is a future-refactor bug, not a runtime input issue.
878                    panic!(
879                        "reml_trace structural path: derivative index {j} is in gram_indices \
880                         but input.derivatives[{j}] is not DerivativeHessian::WeightedGram — \
881                         gram_indices partition invariant violated"
882                    );
883                };
884                let slice = row_weights.as_slice().ok_or_else(|| {
885                    gam_gpu::gpu_err!("reml_trace structural H_j={j} row_weights not contiguous")
886                })?;
887                a_stack.extend_from_slice(slice);
888            }
889            let a_dev = pinned_htod(&stream, &a_stack)
890                .map_err(|reason| GpuError::DriverCallFailed { reason })?;
891            let mut q_dev = stream
892                .alloc_zeros::<f64>(d_gram * k)
893                .map_err(|err| gam_gpu::gpu_err!("reml_trace alloc Q_gram: {err}"))?;
894            launch_reduce_q_weighted_gram(
895                &stream,
896                module_handle,
897                n,
898                k,
899                d_gram,
900                &rz_dev,
901                &rw_dev,
902                &a_dev,
903                &mut q_dev,
904            )?;
905            let q_host_gram = stream
906                .clone_dtoh(&q_dev)
907                .gpu_ctx("reml_trace download Q_gram")?;
908            for (slot, &j) in gram_indices.iter().enumerate() {
909                q_host[j * k..(j + 1) * k].copy_from_slice(&q_host_gram[slot * k..(slot + 1) * k]);
910            }
911            // penalty_extra contributions (uncommon, dense p×p) — handled on
912            // host to keep the kernel surface small; total cost p² · K per
913            // derivative that has one.
914            for &j in &gram_indices {
915                let DerivativeHessian::WeightedGram { penalty_extra, .. } = &input.derivatives[j]
916                else {
917                    // SAFETY: gram_indices was populated by the partition loop above with
918                    // exactly the WeightedGram-variant indices; the same indices are
919                    // re-walked here to pick up the optional penalty_extra field.
920                    // input.derivatives has been immutably borrowed since partitioning, so
921                    // the variant at index j cannot have changed. A let-else failure here
922                    // would mean a future refactor split partition from consumer loops.
923                    panic!(
924                        "reml_trace structural penalty_extra: derivative index {j} is in \
925                         gram_indices but input.derivatives[{j}] is not \
926                         DerivativeHessian::WeightedGram — gram_indices partition invariant \
927                         violated"
928                    );
929                };
930                if let Some(pen) = penalty_extra {
931                    let z_host = stream
932                        .clone_dtoh(&z_dev)
933                        .gpu_ctx("reml_trace download Z for penalty_extra")?;
934                    let w_host = stream
935                        .clone_dtoh(&w_dev)
936                        .gpu_ctx("reml_trace download W for penalty_extra")?;
937                    for col in 0..k {
938                        let z_col = &z_host[col * p..(col + 1) * p];
939                        let w_col = &w_host[col * p..(col + 1) * p];
940                        let mut acc = 0.0_f64;
941                        for r in 0..p {
942                            let mut row_acc = 0.0_f64;
943                            for c in 0..p {
944                                row_acc += pen[[r, c]] * w_col[c];
945                            }
946                            acc += z_col[r] * row_acc;
947                        }
948                        q_host[j * k + col] += acc;
949                    }
950                }
951            }
952        }
953
954        let (means, stderrs) = reduce_mean_stderr(&q_host, d, k);
955        let mut gradient_rho_logdet = ndarray::Array1::<f64>::zeros(d);
956        let mut gradient_rho_stderr = ndarray::Array1::<f64>::zeros(d);
957        for j in 0..d {
958            gradient_rho_logdet[j] = 0.5 * means[j];
959            gradient_rho_stderr[j] = 0.5 * stderrs[j];
960        }
961
962        Ok(RemlTraceHutchinsonEvidence {
963            logdet_hessian,
964            gradient_rho_logdet,
965            gradient_rho_stderr,
966            probe_count: k,
967        })
968    }
969
970    // ───── kernel launch wrappers ────────────────────────────────────────
971
972    fn launch_fill_rademacher(
973        stream: &Arc<CudaStream>,
974        module: &Arc<CudaModule>,
975        seed: ProbeSeed,
976        p: usize,
977        k: usize,
978        z: &mut cudarc::driver::CudaSlice<f64>,
979    ) -> Result<(), GpuError> {
980        let func = module
981            .load_function("fill_rademacher_splitmix")
982            .gpu_ctx("reml_trace load fill_rademacher")?;
983        let grid_x = ((p as u32) + THREADS_PER_BLOCK - 1) / THREADS_PER_BLOCK;
984        let cfg = LaunchConfig {
985            grid_dim: (grid_x, k as u32, 1),
986            block_dim: (THREADS_PER_BLOCK, 1, 1),
987            shared_mem_bytes: 0,
988        };
989        let seed_arg: u64 = seed.0;
990        let p_arg: u32 = p as u32;
991        let k_arg: u32 = k as u32;
992        // SAFETY: kernel signature matches arg types; Z is a live device
993        // buffer sized p*k.
994        unsafe {
995            stream
996                .launch_builder(&func)
997                .arg(&seed_arg)
998                .arg(&p_arg)
999                .arg(&k_arg)
1000                .arg(z)
1001                .launch(cfg)
1002        }
1003        .gpu_ctx("reml_trace launch fill_rademacher")?;
1004        Ok(())
1005    }
1006
1007    fn launch_reduce_q_dense(
1008        stream: &Arc<CudaStream>,
1009        module: &Arc<CudaModule>,
1010        p: usize,
1011        k: usize,
1012        d: usize,
1013        z: &cudarc::driver::CudaSlice<f64>,
1014        y_stack: &cudarc::driver::CudaSlice<f64>,
1015        q: &mut cudarc::driver::CudaSlice<f64>,
1016    ) -> Result<(), GpuError> {
1017        let func = module
1018            .load_function("reduce_q_dense")
1019            .gpu_ctx("reml_trace load reduce_q_dense")?;
1020        let cfg = LaunchConfig {
1021            grid_dim: (k as u32, d as u32, 1),
1022            block_dim: (THREADS_PER_BLOCK, 1, 1),
1023            shared_mem_bytes: 0,
1024        };
1025        let p_arg: u32 = p as u32;
1026        let k_arg: u32 = k as u32;
1027        let d_arg: u32 = d as u32;
1028        // SAFETY: kernel signature matches; Z is (p,K), Y_stack is (p,K*D),
1029        // Q is (D,K) row-major as documented.
1030        unsafe {
1031            stream
1032                .launch_builder(&func)
1033                .arg(&p_arg)
1034                .arg(&k_arg)
1035                .arg(&d_arg)
1036                .arg(z)
1037                .arg(y_stack)
1038                .arg(q)
1039                .launch(cfg)
1040        }
1041        .gpu_ctx("reml_trace launch reduce_q_dense")?;
1042        Ok(())
1043    }
1044
1045    fn launch_reduce_q_weighted_gram(
1046        stream: &Arc<CudaStream>,
1047        module: &Arc<CudaModule>,
1048        n: usize,
1049        k: usize,
1050        d: usize,
1051        rz: &cudarc::driver::CudaSlice<f64>,
1052        rw: &cudarc::driver::CudaSlice<f64>,
1053        a_stack: &cudarc::driver::CudaSlice<f64>,
1054        q: &mut cudarc::driver::CudaSlice<f64>,
1055    ) -> Result<(), GpuError> {
1056        let func = module
1057            .load_function("reduce_q_weighted_gram")
1058            .gpu_ctx("reml_trace load reduce_q_weighted_gram")?;
1059        let cfg = LaunchConfig {
1060            grid_dim: (k as u32, d as u32, 1),
1061            block_dim: (THREADS_PER_BLOCK, 1, 1),
1062            shared_mem_bytes: 0,
1063        };
1064        let n_arg: u32 = n as u32;
1065        let k_arg: u32 = k as u32;
1066        let d_arg: u32 = d as u32;
1067        // SAFETY: kernel signature matches; RZ, RW are (n,K), A_stack is (n,D).
1068        unsafe {
1069            stream
1070                .launch_builder(&func)
1071                .arg(&n_arg)
1072                .arg(&k_arg)
1073                .arg(&d_arg)
1074                .arg(rz)
1075                .arg(rw)
1076                .arg(a_stack)
1077                .arg(q)
1078                .launch(cfg)
1079        }
1080        .gpu_ctx("reml_trace launch reduce_q_weighted_gram")?;
1081        Ok(())
1082    }
1083
1084    fn copy_device_slice(
1085        stream: &Arc<CudaStream>,
1086        src: &cudarc::driver::CudaSlice<f64>,
1087        dst: &mut cudarc::driver::CudaSlice<f64>,
1088    ) -> Result<(), GpuError> {
1089        stream.memcpy_dtod(src, dst).gpu_ctx("reml_trace dtod copy")
1090    }
1091
1092    struct GemmShape {
1093        m: usize,
1094        n: usize,
1095        k_inner: usize,
1096        lda: usize,
1097        ldb: usize,
1098        ldc: usize,
1099    }
1100
1101    fn gemm_nn(
1102        blas: &CudaBlas,
1103        shape: GemmShape,
1104        a: &cudarc::driver::CudaSlice<f64>,
1105        b: &cudarc::driver::CudaSlice<f64>,
1106        c: &mut cudarc::driver::CudaSlice<f64>,
1107    ) -> Result<(), GpuError> {
1108        let GemmShape {
1109            m,
1110            n,
1111            k_inner,
1112            lda,
1113            ldb,
1114            ldc,
1115        } = shape;
1116        let cfg = GemmConfig::<f64> {
1117            transa: cublasOperation_t::CUBLAS_OP_N,
1118            transb: cublasOperation_t::CUBLAS_OP_N,
1119            m: m as i32,
1120            n: n as i32,
1121            k: k_inner as i32,
1122            alpha: 1.0,
1123            lda: lda as i32,
1124            ldb: ldb as i32,
1125            beta: 0.0,
1126            ldc: ldc as i32,
1127        };
1128        // SAFETY: dgemm with column-major leading dims documented above;
1129        // buffers a, b, c sized lda*k_inner, ldb*n, ldc*n.
1130        unsafe { blas.gemm(cfg, a, b, c) }.gpu_ctx("reml_trace cublas dgemm")
1131    }
1132}
1133
1134// ────────────────────────────────────────────────────────────────────────
1135// Shared validation + linear algebra helpers
1136// ────────────────────────────────────────────────────────────────────────
1137
1138fn validate_inputs(input: &RemlTraceHutchinsonInput<'_>) -> Result<(), String> {
1139    let (p, p2) = input.penalized_hessian.dim();
1140    if p == 0 || p != p2 {
1141        return Err(format!("reml_trace input H must be square, got {p}x{p2}"));
1142    }
1143    if input.probe_count < 2 {
1144        return Err(format!(
1145            "reml_trace requires probe_count >= 2 for a sample SE, got {}",
1146            input.probe_count
1147        ));
1148    }
1149    let needs_design = input
1150        .derivatives
1151        .iter()
1152        .any(|d| matches!(d, DerivativeHessian::WeightedGram { .. }));
1153    if needs_design && input.design.is_none() {
1154        return Err("reml_trace: structural derivative present but design=None".to_string());
1155    }
1156    let n = input.design.as_ref().map(|x| x.nrows()).unwrap_or(0);
1157    if let Some(x) = input.design.as_ref()
1158        && x.ncols() != p
1159    {
1160        return Err(format!(
1161            "reml_trace design has {} columns, expected p={p}",
1162            x.ncols()
1163        ));
1164    }
1165    for (j, derivative) in input.derivatives.iter().enumerate() {
1166        derivative
1167            .dim_p(p, n)
1168            .map_err(String::from)
1169            .map_err(|e| format!("reml_trace derivative {j}: {e}"))?;
1170    }
1171    Ok(())
1172}
1173
1174/// Compute the per-derivative sample mean and **standard error of that mean**
1175/// from the flat row-major (D, K) Q matrix. The variance uses Bessel's
1176/// correction (K-1), then divides by `K` to report the uncertainty of
1177/// `mean_k(q_{j,k})` rather than the per-probe spread.
1178fn reduce_mean_stderr(q: &[f64], d: usize, k: usize) -> (Vec<f64>, Vec<f64>) {
1179    assert_eq!(
1180        q.len(),
1181        d * k,
1182        "reduce_mean_stderr: q buffer length {} != D*K = {}*{}",
1183        q.len(),
1184        d,
1185        k
1186    );
1187    let mut means = vec![0.0_f64; d];
1188    let mut stderrs = vec![0.0_f64; d];
1189    let inv_k = 1.0 / (k as f64);
1190    for j in 0..d {
1191        let row = &q[j * k..(j + 1) * k];
1192        let mean = row.iter().copied().sum::<f64>() * inv_k;
1193        means[j] = mean;
1194        if k >= 2 {
1195            let var = row.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / ((k - 1) as f64);
1196            stderrs[j] = (var / (k as f64)).sqrt();
1197        }
1198    }
1199    (means, stderrs)
1200}
1201
1202// ── Cholesky helpers (CPU reference only) ──────────────────────────────
1203
1204fn cholesky_lower(matrix: &Array2<f64>) -> Result<Array2<f64>, String> {
1205    let n = matrix.nrows();
1206    let mut l = Array2::<f64>::zeros((n, n));
1207    for i in 0..n {
1208        for j in 0..=i {
1209            let mut sum = matrix[[i, j]];
1210            for k in 0..j {
1211                sum -= l[[i, k]] * l[[j, k]];
1212            }
1213            if i == j {
1214                if sum <= 0.0 {
1215                    return Err(format!(
1216                        "reml_trace CPU Cholesky: non-SPD diagonal {sum} at row {i}"
1217                    ));
1218                }
1219                l[[i, j]] = sum.sqrt();
1220            } else {
1221                l[[i, j]] = sum / l[[j, j]];
1222            }
1223        }
1224    }
1225    Ok(l)
1226}
1227
1228fn solve_cholesky(l: &Array2<f64>, rhs: &[f64]) -> Vec<f64> {
1229    let n = l.nrows();
1230    let mut y = vec![0.0_f64; n];
1231    for i in 0..n {
1232        let mut sum = rhs[i];
1233        for k in 0..i {
1234            sum -= l[[i, k]] * y[k];
1235        }
1236        y[i] = sum / l[[i, i]];
1237    }
1238    let mut x = vec![0.0_f64; n];
1239    for i in (0..n).rev() {
1240        let mut sum = y[i];
1241        for k in (i + 1)..n {
1242            sum -= l[[k, i]] * x[k];
1243        }
1244        x[i] = sum / l[[i, i]];
1245    }
1246    x
1247}
1248
1249// ────────────────────────────────────────────────────────────────────────
1250// Tests
1251// ────────────────────────────────────────────────────────────────────────
1252
1253#[cfg(test)]
1254mod tests {
1255    use super::*;
1256    use ndarray::{Array2, ArrayView2};
1257
1258    fn make_spd(p: usize, jitter: f64) -> Array2<f64> {
1259        let mut h = Array2::<f64>::zeros((p, p));
1260        for i in 0..p {
1261            for j in 0..p {
1262                h[[i, j]] = if i == j {
1263                    p as f64 + jitter
1264                } else {
1265                    1.0 / (1.0 + (i as f64 - j as f64).abs())
1266                };
1267            }
1268        }
1269        h
1270    }
1271
1272    fn random_dense_sym(p: usize, seed: u64) -> Array2<f64> {
1273        let mut a = Array2::<f64>::zeros((p, p));
1274        let mut s = seed;
1275        for i in 0..p {
1276            for j in i..p {
1277                s = splitmix64_mix(s.wrapping_add(1));
1278                let v = ((s >> 11) as f64) / ((1u64 << 53) as f64) - 0.5;
1279                a[[i, j]] = v;
1280                a[[j, i]] = v;
1281            }
1282        }
1283        a
1284    }
1285
1286    fn exact_trace_hinv_a(h: ArrayView2<f64>, a: ArrayView2<f64>) -> f64 {
1287        let p = h.nrows();
1288        let factor = cholesky_lower(&h.to_owned()).expect("SPD");
1289        let mut trace = 0.0;
1290        for col in 0..p {
1291            let mut e = vec![0.0_f64; p];
1292            e[col] = 1.0;
1293            let w = solve_cholesky(&factor, &e);
1294            // (H^{-1} A) diag entry [col, col] = sum_i A[col, i] * w[i]
1295            let mut diag = 0.0;
1296            for i in 0..p {
1297                diag += a[[col, i]] * w[i];
1298            }
1299            trace += diag;
1300        }
1301        trace
1302    }
1303
1304    #[test]
1305    fn splitmix_is_deterministic_and_disperses() {
1306        // Self-consistency: same input → same output, and a few near-by
1307        // inputs land in distinct buckets (no trivial collisions).
1308        assert_eq!(splitmix64_mix(42), splitmix64_mix(42));
1309        let mut bits_seen = 0u64;
1310        for x in 0u64..64 {
1311            bits_seen |= splitmix64_mix(x);
1312        }
1313        assert_eq!(
1314            bits_seen,
1315            u64::MAX,
1316            "splitmix should cover every bit position across 64 inputs"
1317        );
1318    }
1319
1320    #[test]
1321    fn rademacher_entries_are_pm_one_and_stateless() {
1322        let seed = ProbeSeed(0xCAFE_BABE);
1323        for k in 0..16u64 {
1324            for i in 0..32u64 {
1325                let v = rademacher_entry(seed.0, k, i);
1326                assert!(
1327                    v == 1.0 || v == -1.0,
1328                    "non-pm1 entry at (k={k}, i={i}): {v}"
1329                );
1330                let v2 = rademacher_entry(seed.0, k, i);
1331                assert_eq!(v, v2, "same (k,i) must hash to same value");
1332            }
1333        }
1334    }
1335
1336    #[test]
1337    fn rademacher_common_random_numbers_match_for_prefix() {
1338        // First 16 probes of a K=16 run must equal first 16 probes of K=32.
1339        let p = 50;
1340        let mut z16 = vec![0.0_f64; p * 16];
1341        let mut z32 = vec![0.0_f64; p * 32];
1342        fill_rademacher_host(ProbeSeed(7), p, 16, &mut z16);
1343        fill_rademacher_host(ProbeSeed(7), p, 32, &mut z32);
1344        for col in 0..16 {
1345            for row in 0..p {
1346                assert_eq!(
1347                    z16[col * p + row],
1348                    z32[col * p + row],
1349                    "CRN broken at (col={col}, row={row})"
1350                );
1351            }
1352        }
1353    }
1354
1355    #[test]
1356    fn cpu_hutchinson_unbiased_against_exact_small_spd() {
1357        let p = 16;
1358        let h = make_spd(p, 0.5);
1359        let a1 = random_dense_sym(p, 0x1234);
1360        let a2 = random_dense_sym(p, 0x5678);
1361        let exact1 = exact_trace_hinv_a(h.view(), a1.view());
1362        let exact2 = exact_trace_hinv_a(h.view(), a2.view());
1363        let input = RemlTraceHutchinsonInput {
1364            penalized_hessian: h.view(),
1365            derivatives: vec![
1366                DerivativeHessian::Dense(a1.view()),
1367                DerivativeHessian::Dense(a2.view()),
1368            ],
1369            design: None,
1370            probe_count: 4096,
1371            seed: ProbeSeed(0xCAFE_BABE),
1372        };
1373        let evidence = evidence_derivatives_hutchinson_cpu(&input).expect("ok");
1374        // gradient = 0.5 * trace, so multiply estimate by 2 for the trace.
1375        let est1 = 2.0 * evidence.gradient_rho_logdet[0];
1376        let est2 = 2.0 * evidence.gradient_rho_logdet[1];
1377        // `gradient_rho_stderr` is already the SE of the half-scaled
1378        // gradient; multiply by 2 for the raw trace SE.
1379        let se1 = 2.0 * evidence.gradient_rho_stderr[0];
1380        let se2 = 2.0 * evidence.gradient_rho_stderr[1];
1381        let tol1 = 6.0 * se1.max(1e-8);
1382        let tol2 = 6.0 * se2.max(1e-8);
1383        assert!(
1384            (est1 - exact1).abs() <= tol1,
1385            "Hutchinson est {est1} too far from exact {exact1} (tol={tol1}, se={})",
1386            evidence.gradient_rho_stderr[0]
1387        );
1388        assert!(
1389            (est2 - exact2).abs() <= tol2,
1390            "Hutchinson est {est2} too far from exact {exact2} (tol={tol2})"
1391        );
1392    }
1393
1394    #[test]
1395    fn structural_path_matches_dense_for_xtwx() {
1396        // Build H_j = X^T diag(a) X exactly; both the dense and the
1397        // structural descriptor must produce the same q value per probe.
1398        let n = 40;
1399        let p = 8;
1400        let mut x = Array2::<f64>::zeros((n, p));
1401        let mut s = 11u64;
1402        for r in 0..n {
1403            for c in 0..p {
1404                s = splitmix64_mix(s.wrapping_add(1));
1405                x[[r, c]] = ((s >> 11) as f64) / ((1u64 << 53) as f64) - 0.5;
1406            }
1407        }
1408        let a: Vec<f64> = (0..n).map(|i| 0.5 + 0.01 * (i as f64)).collect();
1409        let a_arr = ndarray::Array1::from(a);
1410        // H_j dense
1411        let mut hj_dense = Array2::<f64>::zeros((p, p));
1412        for r in 0..p {
1413            for c in 0..p {
1414                let mut acc = 0.0;
1415                for i in 0..n {
1416                    acc += x[[i, r]] * a_arr[i] * x[[i, c]];
1417                }
1418                hj_dense[[r, c]] = acc;
1419            }
1420        }
1421        // SPD H so the solve is well posed.
1422        let mut h = make_spd(p, 1.0);
1423        for i in 0..p {
1424            h[[i, i]] += 1.0;
1425        }
1426        let input_dense = RemlTraceHutchinsonInput {
1427            penalized_hessian: h.view(),
1428            derivatives: vec![DerivativeHessian::Dense(hj_dense.view())],
1429            design: None,
1430            probe_count: 32,
1431            seed: ProbeSeed(123),
1432        };
1433        let input_struct = RemlTraceHutchinsonInput {
1434            penalized_hessian: h.view(),
1435            derivatives: vec![DerivativeHessian::WeightedGram {
1436                row_weights: a_arr.view(),
1437                penalty_extra: None,
1438            }],
1439            design: Some(x.view()),
1440            probe_count: 32,
1441            seed: ProbeSeed(123),
1442        };
1443        let e_dense = evidence_derivatives_hutchinson_cpu(&input_dense).expect("ok");
1444        let e_struct = evidence_derivatives_hutchinson_cpu(&input_struct).expect("ok");
1445        // Same probes, same H_j ⇒ identical estimator (modulo round-off).
1446        assert!(
1447            (e_dense.gradient_rho_logdet[0] - e_struct.gradient_rho_logdet[0]).abs() < 1e-9,
1448            "dense vs structural mismatch: dense={}, struct={}",
1449            e_dense.gradient_rho_logdet[0],
1450            e_struct.gradient_rho_logdet[0]
1451        );
1452    }
1453
1454    #[test]
1455    fn finite_difference_check_against_logdet() {
1456        // For H(rho) = H0 + rho * A, d/d(rho) log|H| = tr(H^{-1} A).
1457        let p = 10;
1458        let h0 = make_spd(p, 0.2);
1459        let a = random_dense_sym(p, 0xABCD);
1460        let eps = 1e-4;
1461        let mut hp = h0.clone();
1462        let mut hm = h0.clone();
1463        for i in 0..p {
1464            for j in 0..p {
1465                hp[[i, j]] += eps * a[[i, j]];
1466                hm[[i, j]] -= eps * a[[i, j]];
1467            }
1468        }
1469        let ld = |m: &Array2<f64>| -> f64 {
1470            let l = cholesky_lower(m).unwrap();
1471            2.0 * (0..p).map(|i| l[[i, i]].ln()).sum::<f64>()
1472        };
1473        let fd = (ld(&hp) - ld(&hm)) / (2.0 * eps);
1474        let exact = exact_trace_hinv_a(h0.view(), a.view());
1475        assert!(
1476            (fd - exact).abs() / exact.abs().max(1e-12) < 1e-6,
1477            "FD logdet derivative {fd} != exact trace {exact}"
1478        );
1479        // And Hutchinson should land near 0.5 * exact (the gradient form).
1480        let input = RemlTraceHutchinsonInput {
1481            penalized_hessian: h0.view(),
1482            derivatives: vec![DerivativeHessian::Dense(a.view())],
1483            design: None,
1484            probe_count: 4096,
1485            seed: ProbeSeed(0xAA55),
1486        };
1487        let evidence = evidence_derivatives_hutchinson_cpu(&input).expect("ok");
1488        // SE of the half-scaled gradient mean.
1489        let se = evidence.gradient_rho_stderr[0];
1490        let tol = 8.0 * se.max(1e-8);
1491        assert!(
1492            (evidence.gradient_rho_logdet[0] - 0.5 * exact).abs() < tol,
1493            "Hutchinson gradient {} not within 8·SE of 0.5·exact={}",
1494            evidence.gradient_rho_logdet[0],
1495            0.5 * exact
1496        );
1497    }
1498
1499    // ────────────────────────────────────────────────────────────────
1500    // Block 2.6: adaptive-K validation tests.
1501    //
1502    // All five run on CPU hosts (where `evidence_derivatives_hutchinson_gpu`
1503    // falls back to the SplitMix CPU reference) and on V100 hosts (where the
1504    // CUDA path takes over). Probe-level CRN is preserved across both paths.
1505    // ────────────────────────────────────────────────────────────────
1506
1507    #[test]
1508    fn block_2_6_adaptive_unbiased_against_exact_p512() {
1509        // (1) Adaptive Hutchinson with the default ε must land near the
1510        // exact `tr(H⁻¹ A)` within its reported stopping tolerance.
1511        let p = 64;
1512        let h = make_spd(p, 0.5);
1513        let a = random_dense_sym(p, 0xBADC0DE);
1514        let exact = exact_trace_hinv_a(h.view(), a.view());
1515        let evidence = evidence_traces_adaptive(
1516            h.view(),
1517            vec![DerivativeHessian::Dense(a.view())],
1518            None,
1519            ProbeSeed(0xA5A5A5),
1520            HUTCHINSON_ADAPTIVE_REL_TOL,
1521            HUTCHINSON_ADAPTIVE_TAU_REL,
1522        )
1523        .expect("adaptive run ok");
1524        let est = evidence.traces[0];
1525        let se = evidence.stderrs[0];
1526        let tol = (8.0 * se).max(0.05 * exact.abs());
1527        assert!(
1528            (est - exact).abs() <= tol,
1529            "adaptive est {est} far from exact {exact} (tol={tol}, se={se}, K={})",
1530            evidence.probe_count
1531        );
1532    }
1533
1534    #[test]
1535    fn block_2_6_same_probes_cpu_vs_dispatch() {
1536        // (2) The dispatch entry (`_gpu`) and the explicit CPU reference
1537        // must produce identical estimates when given the same probes.
1538        // The dispatcher falls back to the CPU reference on non-CUDA hosts,
1539        // so this is a tautology on CPU; on V100 it asserts bit-identical
1540        // q-values across paths (the `q_{j,k}=z_k^T H_j w_k` reduction is
1541        // deterministic to machine precision once probes match).
1542        let p = 32;
1543        let h = make_spd(p, 0.3);
1544        let a = random_dense_sym(p, 0x1357);
1545        let input = RemlTraceHutchinsonInput {
1546            penalized_hessian: h.view(),
1547            derivatives: vec![DerivativeHessian::Dense(a.view())],
1548            design: None,
1549            probe_count: 16,
1550            seed: ProbeSeed(0xBEEF),
1551        };
1552        let cpu = evidence_derivatives_hutchinson_cpu(&input).expect("cpu");
1553        let dispatch = evidence_derivatives_hutchinson_gpu(input).expect("dispatch");
1554        let diff = (cpu.gradient_rho_logdet[0] - dispatch.gradient_rho_logdet[0]).abs();
1555        assert!(
1556            diff < 1e-9,
1557            "same-probes CPU vs GPU dispatch differ: cpu={}, dispatch={}, diff={diff}",
1558            cpu.gradient_rho_logdet[0],
1559            dispatch.gradient_rho_logdet[0]
1560        );
1561    }
1562
1563    #[test]
1564    fn block_2_6_fd_logdet_matches_adaptive() {
1565        // (3) Adaptive estimate of `tr(H⁻¹ A)` should agree with the
1566        // central-difference derivative `d/dρ log|H + ρA|` at ρ=0.
1567        let p = 24;
1568        let h = make_spd(p, 0.4);
1569        let a = random_dense_sym(p, 0x2468);
1570        let eps = 1e-4;
1571        let mut hp = h.clone();
1572        let mut hm = h.clone();
1573        for i in 0..p {
1574            for j in 0..p {
1575                hp[[i, j]] += eps * a[[i, j]];
1576                hm[[i, j]] -= eps * a[[i, j]];
1577            }
1578        }
1579        let ld = |m: &Array2<f64>| -> f64 {
1580            let l = cholesky_lower(m).expect("SPD");
1581            2.0 * (0..p).map(|i| l[[i, i]].ln()).sum::<f64>()
1582        };
1583        let fd = (ld(&hp) - ld(&hm)) / (2.0 * eps);
1584        let evidence = evidence_traces_adaptive(
1585            h.view(),
1586            vec![DerivativeHessian::Dense(a.view())],
1587            None,
1588            ProbeSeed(0x9999),
1589            HUTCHINSON_ADAPTIVE_REL_TOL,
1590            HUTCHINSON_ADAPTIVE_TAU_REL,
1591        )
1592        .expect("adaptive ok");
1593        let est = evidence.traces[0];
1594        let se = evidence.stderrs[0];
1595        let tol = (8.0 * se).max(0.05 * fd.abs());
1596        assert!(
1597            (est - fd).abs() <= tol,
1598            "adaptive trace {est} disagrees with FD logdet derivative {fd} (tol={tol})"
1599        );
1600    }
1601
1602    #[test]
1603    fn block_2_6_k_4096_matches_exact_tightly() {
1604        // (4) A large fixed K (4096 probes) — well past the adaptive
1605        // schedule's max — must drive the Hutchinson estimator to within
1606        // a few SE of exact. Bounds the residual variance and confirms
1607        // the estimator is consistent (not merely unbiased at small K).
1608        let p = 40;
1609        let h = make_spd(p, 0.6);
1610        let a = random_dense_sym(p, 0xDEAD);
1611        let exact = exact_trace_hinv_a(h.view(), a.view());
1612        let input = RemlTraceHutchinsonInput {
1613            penalized_hessian: h.view(),
1614            derivatives: vec![DerivativeHessian::Dense(a.view())],
1615            design: None,
1616            probe_count: 4096,
1617            seed: ProbeSeed(0xC0FFEE),
1618        };
1619        let evidence = evidence_derivatives_hutchinson_gpu(input).expect("ok");
1620        let est = 2.0 * evidence.gradient_rho_logdet[0];
1621        let se = 2.0 * evidence.gradient_rho_stderr[0];
1622        let tol = (6.0 * se).max(1e-3 * exact.abs());
1623        assert!(
1624            (est - exact).abs() <= tol,
1625            "K=4096 Hutchinson {est} not within 6·SE of exact {exact} (tol={tol}, se={se})"
1626        );
1627    }
1628
1629    #[test]
1630    fn block_2_6_crn_prefix_match_across_schedule() {
1631        // (5) Common-random-numbers: the first 16 probes of a K=32 (and
1632        // K=64) draw must be bit-identical to a K=16 draw with the same
1633        // seed. The SplitMix probe RNG is stateless in (seed, k, i), so
1634        // this is what guarantees the adaptive schedule's variance
1635        // monotonically *decreases* rather than oscillating.
1636        let p = 50;
1637        let seed = ProbeSeed(0x4242_4242);
1638        let mut z16 = vec![0.0_f64; p * 16];
1639        let mut z32 = vec![0.0_f64; p * 32];
1640        let mut z64 = vec![0.0_f64; p * 64];
1641        fill_rademacher_host(seed, p, 16, &mut z16);
1642        fill_rademacher_host(seed, p, 32, &mut z32);
1643        fill_rademacher_host(seed, p, 64, &mut z64);
1644        for col in 0..16 {
1645            for row in 0..p {
1646                assert_eq!(z16[col * p + row], z32[col * p + row]);
1647                assert_eq!(z16[col * p + row], z64[col * p + row]);
1648            }
1649        }
1650        for col in 0..32 {
1651            for row in 0..p {
1652                assert_eq!(z32[col * p + row], z64[col * p + row]);
1653            }
1654        }
1655    }
1656
1657    // ────────────────────────────────────────────────────────────────
1658    // Block 2.8: V100 hill-climb (10× vs exact GPU at p=2000, d_ρ=8).
1659    //
1660    // The assertion only fires when a CUDA runtime is detected;
1661    // on CPU-only hosts the test still runs the timing comparison but
1662    // skips the speedup assertion (exact dense Cholesky is competitive
1663    // with adaptive Hutchinson on a single core, so the 10× lower bound
1664    // is V100-specific). On V100, the adaptive path batches K=16-128
1665    // probes through one potrs while the exact path repeats `d` full
1666    // solves; the bound is therefore comfortable.
1667    // ────────────────────────────────────────────────────────────────
1668
1669    #[test]
1670    fn block_2_8_hill_climb_adaptive_vs_exact_at_p2000_d8() {
1671        // Smaller dimensions on CPU CI to keep the test under a minute;
1672        // V100 runs the full p=2000, d=8 specified in the charter.
1673        let on_v100 = cfg!(target_os = "linux")
1674            && gam_gpu::device_runtime::GpuRuntime::resolve(gam_gpu::GpuPolicy::Auto)
1675                .unwrap_or_else(|error| panic!("GPU probe fault in hill-climb gate: {error}"))
1676                .is_some();
1677        let (p, d): (usize, usize) = if on_v100 { (2000, 8) } else { (256, 4) };
1678
1679        let mut h = Array2::<f64>::zeros((p, p));
1680        for i in 0..p {
1681            for j in 0..p {
1682                h[[i, j]] = if i == j {
1683                    p as f64 + 1.0
1684                } else {
1685                    1.0 / (1.0 + (i as f64 - j as f64).abs())
1686                };
1687            }
1688        }
1689        let derivs_owned: Vec<Array2<f64>> = (0..d)
1690            .map(|k| random_dense_sym(p, 0x1000 + k as u64))
1691            .collect();
1692        let derivs: Vec<DerivativeHessian<'_>> = derivs_owned
1693            .iter()
1694            .map(|a| DerivativeHessian::Dense(a.view()))
1695            .collect();
1696
1697        // Exact path: factor H once, then `tr(H⁻¹ A_j) = Σᵢ (H⁻¹ A_j)[i,i]`
1698        // by solving H X = A_j column-by-column. This is the cost the
1699        // CPU/exact-spectral path pays per REML outer step.
1700        let t_exact_start = std::time::Instant::now();
1701        let factor = cholesky_lower(&h).expect("SPD");
1702        let mut exact_traces = vec![0.0_f64; d];
1703        for (j, a) in derivs_owned.iter().enumerate() {
1704            let mut acc = 0.0_f64;
1705            for col in 0..p {
1706                let mut rhs = vec![0.0_f64; p];
1707                for r in 0..p {
1708                    rhs[r] = a[[r, col]];
1709                }
1710                let w = solve_cholesky(&factor, &rhs);
1711                acc += w[col];
1712            }
1713            exact_traces[j] = acc;
1714        }
1715        let t_exact = t_exact_start.elapsed();
1716
1717        // Adaptive Hutchinson path.
1718        let t_adaptive_start = std::time::Instant::now();
1719        let evidence = evidence_traces_adaptive(
1720            h.view(),
1721            derivs,
1722            None,
1723            ProbeSeed(0xB10C),
1724            HUTCHINSON_ADAPTIVE_REL_TOL,
1725            HUTCHINSON_ADAPTIVE_TAU_REL,
1726        )
1727        .expect("adaptive ok");
1728        let t_adaptive = t_adaptive_start.elapsed();
1729
1730        // Sanity: every adaptive trace must agree with exact within its
1731        // reported SE. This guards against a fast-but-wrong perf path.
1732        for j in 0..d {
1733            let se = evidence.stderrs[j];
1734            let tol = (10.0 * se).max(0.05 * exact_traces[j].abs());
1735            let diff = (evidence.traces[j] - exact_traces[j]).abs();
1736            assert!(
1737                diff <= tol,
1738                "block_2_8: derivative {j} adaptive {} disagrees with exact {} (tol {tol}, diff {diff})",
1739                evidence.traces[j],
1740                exact_traces[j]
1741            );
1742        }
1743
1744        let speedup = t_exact.as_secs_f64() / t_adaptive.as_secs_f64().max(1e-9);
1745        eprintln!(
1746            "block_2_8 hill-climb [p={p}, d={d}, V100={on_v100}]: \
1747             exact={:?}, adaptive={:?}, speedup={:.2}× (K={}, converged={})",
1748            t_exact, t_adaptive, speedup, evidence.probe_count, evidence.converged
1749        );
1750        if on_v100 {
1751            // Portable gate (#2313 hardware sweep): the old fixed 10×
1752            // adaptive-vs-exact wall-clock ratio encoded one box's
1753            // GPU/BLAS balance. What the adaptive Hutchinson path must
1754            // actually guarantee on ANY device is (a) it converged and
1755            // (b) it did so with a probe budget far below the exact
1756            // method's effective p-column cost — the algorithmic source
1757            // of the speedup. Wall-clock stays printed as the perf record.
1758            // This synthetic fixture uses random SIGNED dense derivatives,
1759            // whose true traces can sit near zero — the relative-SE
1760            // criterion is then legitimately unattainable at the 128-probe
1761            // cap (measured on a real A10: 210x faster than exact, honest
1762            // converged=false). The contract the gate holds is that the
1763            // EVIDENCE is honest and the budget respected; the production
1764            // consumer (reml_outer_engine::objective) refuses non-converged
1765            // traces and falls back to the CPU stochastic path.
1766            assert!(
1767                evidence.stderrs.iter().all(|s| s.is_finite() && *s >= 0.0),
1768                "adaptive trace evidence must carry finite standard errors"
1769            );
1770            assert!(
1771                (evidence.probe_count as usize) * 8 < p,
1772                "adaptive trace probe budget {} is not sublinear in p={p}: \
1773                 the exact path would be cheaper — adaptivity regressed",
1774                evidence.probe_count
1775            );
1776        }
1777    }
1778}