Skip to main content

gam_solve/gpu/
pirls_gpu.rs

1use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
2
3/// Family-aware scalar contract for the built-in GPU PIRLS row kernels.
4///
5/// The CUDA kernel ABI still has one `double` slot shared by every built-in
6/// family, but only Gamma is allowed to populate it. Non-Gamma callers carry
7/// an explicit discriminant instead of manufacturing a unit Gamma shape; the
8/// final ABI conversion writes a NaN poison value so any future accidental
9/// non-Gamma read fails loudly rather than silently becoming unit scale.
10#[derive(Clone, Copy, Debug, PartialEq)]
11pub struct PirlsLoopLikelihoodScale(PirlsLoopLikelihoodScaleKind);
12
13#[derive(Clone, Copy, Debug, PartialEq)]
14enum PirlsLoopLikelihoodScaleKind {
15    NonGamma,
16    GammaShape(f64),
17}
18
19impl PirlsLoopLikelihoodScale {
20    #[inline]
21    pub const fn non_gamma() -> Self {
22        Self(PirlsLoopLikelihoodScaleKind::NonGamma)
23    }
24
25    pub fn gamma_shape(shape: f64) -> Result<Self, String> {
26        if shape.is_finite() && shape > 0.0 {
27            Ok(Self(PirlsLoopLikelihoodScaleKind::GammaShape(shape)))
28        } else {
29            Err(format!(
30                "GPU PIRLS Gamma shape must be finite and strictly positive, got {shape:?}"
31            ))
32        }
33    }
34
35    #[cfg(target_os = "linux")]
36    fn kernel_argument(
37        self,
38        family: crate::gpu_kernels::pirls_row::PirlsRowFamily,
39    ) -> Result<f64, String> {
40        use crate::gpu_kernels::pirls_row::PirlsRowFamily;
41        match (family, self.0) {
42            (PirlsRowFamily::GammaLog, PirlsLoopLikelihoodScaleKind::GammaShape(shape)) => {
43                Ok(shape)
44            }
45            (PirlsRowFamily::GammaLog, PirlsLoopLikelihoodScaleKind::NonGamma) => {
46                Err("GPU Gamma row kernel requires an explicit resolved Gamma shape".to_string())
47            }
48            (_, PirlsLoopLikelihoodScaleKind::NonGamma) => Ok(f64::NAN),
49            (_, PirlsLoopLikelihoodScaleKind::GammaShape(shape)) => Err(format!(
50                "GPU non-Gamma row kernel {family:?} received Gamma shape {shape:?}"
51            )),
52        }
53    }
54}
55
56#[derive(Clone, Debug)]
57pub struct PirlsGpuInput<'a> {
58    pub x: ArrayView2<'a, f64>,
59    pub weights: ArrayView1<'a, f64>,
60    pub penalty_hessian: ArrayView2<'a, f64>,
61    /// Full descent-direction RHS: `Xᵀ·score − S·β + linear_shift`. The
62    /// returned `PirlsGpuStep::direction = H⁻¹·gradient` (no negation, #257).
63    /// Callers must assemble the corrected RHS before passing it here.
64    pub gradient: ArrayView1<'a, f64>,
65    /// Temporary Levenberg–Marquardt damping; added to H for the solve
66    /// only. Never enters the exported `penalized_hessian`, `RidgePassport`,
67    /// EDF, REML curvature, or penalty term.
68    pub step_lm_lambda: f64,
69    /// Real model-objective ridge. Enters the exported `penalized_hessian`,
70    /// `RidgePassport`, EDF, REML curvature, and penalty term.
71    pub objective_ridge: f64,
72}
73
74#[derive(Clone, Debug)]
75pub struct PirlsGpuStep {
76    pub penalized_hessian: Array2<f64>,
77    pub direction: Array1<f64>,
78    pub logdet: f64,
79}
80
81/// Per-step inputs for [`solve_pirls_step_on_stream`].
82///
83/// Mirrors [`PirlsGpuInput`] but elides the design matrix `x` because that
84/// lives device-resident in the shared batch state. Each PIRLS Newton step
85/// only changes `weights`, `penalty_hessian` (with the current Sλ sum),
86/// `gradient`, and the LM ridge — these are the small per-step uploads the
87/// stream-pool path streams to the device.
88#[derive(Clone, Debug)]
89pub struct PirlsStepStreamInput<'a> {
90    pub weights: ArrayView1<'a, f64>,
91    pub penalty_hessian: ArrayView2<'a, f64>,
92    pub gradient: ArrayView1<'a, f64>,
93    /// Temporary LM damping for this Newton solve step only. Added to H
94    /// before potrf; stripped out of the snapshotted `penalized_hessian`.
95    pub step_lm_lambda: f64,
96    /// Real model-objective ridge. Appears in the exported
97    /// `penalized_hessian` that flows to EDF / REML curvature.
98    pub objective_ridge: f64,
99}
100
101/// Stage 3.2 device-input variant of [`PirlsStepStreamInput`].
102///
103/// Where the host-input form uploads `weights` + `gradient` per Newton
104/// step, this form reads them straight from the
105/// [`crate::gpu_kernels::pirls_row::RowOutputDevBuffers`] populated by the
106/// device-side row-reweight kernel — no host round-trip for the row
107/// state. Only the penalty matrix still crosses the host boundary
108/// because the outer REML loop updates Sλ + LM ridge between PIRLS
109/// steps.
110#[cfg(target_os = "linux")]
111pub struct PirlsStepStreamDeviceInput<'a, 'b> {
112    /// Device-resident solver weights `w_solver_i` (length n). Read
113    /// in-place by the cublasDdgmm WX assembly.
114    pub w_solver_dev: &'a cudarc::driver::CudaSlice<f64>,
115    /// Device-resident IRLS gradient `∂ℓ/∂η_i` (length n). Read by the
116    /// `Xᵀg` dgemv to form the Newton RHS.
117    pub grad_eta_dev: &'b cudarc::driver::CudaSlice<f64>,
118    /// Penalty Hessian Sλ in row-major host layout (p × p).
119    pub penalty_hessian: ArrayView2<'b, f64>,
120    /// Temporary LM damping for this Newton solve step only. Added to H
121    /// before potrf; stripped out of the snapshotted `penalized_hessian`.
122    pub step_lm_lambda: f64,
123    /// Real model-objective ridge. Appears in the exported
124    /// `penalized_hessian` that flows to EDF / REML curvature.
125    pub objective_ridge: f64,
126    /// Current coefficient vector β (length p). Downloaded to the host to
127    /// form the Newton RHS correction S·β. Only p f64 values cross the
128    /// boundary (β is small), so the round-trip cost is negligible.
129    pub beta_dev: &'b cudarc::driver::CudaSlice<f64>,
130    /// Linear shift vector (length p) in transformed coordinates, on host.
131    /// Added to Newton RHS so the solve targets Xᵀ·score − S·β + linear_shift.
132    pub linear_shift: ArrayView1<'b, f64>,
133}
134
135/// Shared, batch-wide GPU state for stream-pool sigma-cubature PIRLS.
136///
137/// Construct once per model via [`upload_shared_pirls_gpu`] and hand a
138/// shared reference to many [`SigmaPirlsGpuWorkspace`]s. X_original, y,
139/// prior_w, and offset are uploaded once and reused across all ρ / σ
140/// points. Per ρ / σ point, only the small `Qs` reparam matrix is
141/// re-uploaded into the workspace.
142#[cfg(target_os = "linux")]
143pub struct PirlsGpuSharedData {
144    pub(crate) ctx: std::sync::Arc<cudarc::driver::CudaContext>,
145    pub(crate) n: usize,
146    pub(crate) p: usize,
147    /// `n*p` f64 column-major **original** design matrix `X_original`,
148    /// device-resident. Never the pre-multiplied `X·Qs` form.
149    pub(crate) x_original_dev: cudarc::driver::CudaSlice<f64>,
150    /// Response vector `y`, length `n`, device-resident.
151    pub(crate) y_dev: cudarc::driver::CudaSlice<f64>,
152    /// Prior weights, length `n`, device-resident.
153    pub(crate) prior_w_dev: cudarc::driver::CudaSlice<f64>,
154    /// Observation offset, length `n`, device-resident.
155    pub(crate) offset_dev: cudarc::driver::CudaSlice<f64>,
156}
157
158/// Per-stream workspace for [`solve_pirls_step_on_stream`].
159///
160/// Owns a non-default CUDA stream plus cuBLAS / cuSOLVER handles bound to
161/// that stream, and the persistent device buffers that every PIRLS Newton
162/// step in this sigma fit reuses (no per-step allocation, no per-step
163/// handle creation). Multiple workspaces on independent streams sharing
164/// one [`PirlsGpuSharedData`] are the substrate the stream-pool cubature
165/// executor (Block 6 P3) composes.
166///
167/// When `p < FUSED_XTWX_P_THRESHOLD`, the workspace skips the `n×p` `wx_dev`
168/// temporary entirely and routes through the fused `xtwx_lower` + `xtscore`
169/// kernels instead. `wx_dev` is `Some` only for the large-p fallback path
170/// where `ddgmm + gemm` beats the fused kernel.
171#[cfg(target_os = "linux")]
172pub struct SigmaPirlsGpuWorkspace {
173    pub(crate) stream: std::sync::Arc<cudarc::driver::CudaStream>,
174    pub(crate) blas: cudarc::cublas::CudaBlas,
175    pub(crate) solver: cudarc::cusolver::DnHandle,
176    /// `None` when `p < FUSED_XTWX_P_THRESHOLD` (fused path). `Some` for the
177    /// large-p fallback where the `ddgmm + dgemm` route is faster.
178    pub(crate) wx_dev: Option<cudarc::driver::CudaSlice<f64>>,
179    pub(crate) w_dev: cudarc::driver::CudaSlice<f64>,
180    /// `X_originalᵀ W X_original` (p×p) — intermediate before Qs projection.
181    pub(crate) xtwx_dev: cudarc::driver::CudaSlice<f64>,
182    pub(crate) h_dev: cudarc::driver::CudaSlice<f64>,
183    pub(crate) rhs_dev: cudarc::driver::CudaSlice<f64>,
184    pub(crate) penalty_dev: cudarc::driver::CudaSlice<f64>,
185    /// Reparameterisation matrix `Qs` (p×p, column-major), uploaded once per
186    /// ρ / σ point. Identity when no reparameterisation is active. Used to
187    /// project `A = X_originalᵀ W X_original` into the transformed frame:
188    /// `H_step = Qsᵀ A Qs + S + λI`.
189    pub(crate) qs_dev: cudarc::driver::CudaSlice<f64>,
190    /// Scratch p×p buffer for the two-step `Qsᵀ A Qs` accumulation:
191    /// first `tmp = A Qs`, then `H = Qsᵀ tmp`.
192    pub(crate) qs_tmp_dev: cudarc::driver::CudaSlice<f64>,
193    /// p-vector: `beta_orig = Qs · β` computed before each `eta = X · beta_orig`.
194    pub(crate) beta_orig_dev: cudarc::driver::CudaSlice<f64>,
195    /// p-vector scratch used for `Qs · direction` when forming `xd = X · (Qs · δ)`.
196    pub(crate) dir_orig_dev: cudarc::driver::CudaSlice<f64>,
197    /// Pre-allocated cuSOLVER POTRF workspace buffer. Sized once at
198    /// construction via `potrf_query_lwork`; reused every Newton step.
199    pub(crate) potrf_work_dev: cudarc::driver::CudaSlice<f64>,
200    /// Number of f64 elements in `potrf_work_dev`, stored as i32 to match
201    /// the cuSOLVER API signature for cusolverDnDpotrf.
202    pub(crate) potrf_lwork: i32,
203    /// Deferred POTRF info scalar. Stays device-resident across all PIRLS
204    /// Newton steps; downloaded once at end-of-fit via
205    /// `check_deferred_potrf_info`.
206    pub(crate) potrf_info_dev: cudarc::driver::CudaSlice<i32>,
207    /// Deferred POTRS info scalar. Mirrors the POTRF discipline.
208    pub(crate) potrs_info_dev: cudarc::driver::CudaSlice<i32>,
209    pub(crate) n: usize,
210    pub(crate) p: usize,
211}
212
213#[cfg(target_os = "linux")]
214pub(crate) mod cuda {
215    use super::{
216        PirlsGpuInput, PirlsGpuSharedData, PirlsGpuStep, PirlsStepStreamDeviceInput,
217        PirlsStepStreamInput, SigmaPirlsGpuWorkspace,
218    };
219    use cudarc::cublas::sys::{
220        cublasDdgmm, cublasDgeam, cublasOperation_t, cublasSideMode_t, cublasStatus_t,
221    };
222    use cudarc::cublas::{CudaBlas, Gemm, GemmConfig, Gemv, GemvConfig};
223    use cudarc::cusolver::DnHandle;
224    use cudarc::driver::{CudaSlice, DevicePtr, DevicePtrMut, LaunchConfig, PushKernelArg};
225    use gam_gpu::device_cache::PtxModuleCache;
226    use gam_gpu::driver::{from_col_major, to_col_major};
227    use gam_gpu::solver::{
228        check_deferred_potrf_info, check_deferred_potrs_info, context_and_stream, pinned_htod,
229        potrf_in_place_reuse, potrf_query_lwork, potrs_in_place_reuse,
230    };
231    use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
232
233    /// Device/runtime failures stay distinct from exact statistical row
234    /// refusals.  The latter cross the GPU dispatch boundary as their original
235    /// typed [`gam_problem::EstimationError`] instead of being stringified or
236    /// retried on a different numerical path.
237    #[derive(Debug)]
238    pub enum PirlsGpuLoopError {
239        Geometry(gam_problem::EstimationError),
240        Runtime(String),
241    }
242
243    impl std::fmt::Display for PirlsGpuLoopError {
244        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245            match self {
246                Self::Geometry(error) => write!(f, "{error}"),
247                Self::Runtime(message) => f.write_str(message),
248            }
249        }
250    }
251
252    impl std::error::Error for PirlsGpuLoopError {}
253
254    impl From<String> for PirlsGpuLoopError {
255        fn from(message: String) -> Self {
256            Self::Runtime(message)
257        }
258    }
259
260    impl From<&str> for PirlsGpuLoopError {
261        fn from(message: &str) -> Self {
262            Self::Runtime(message.to_owned())
263        }
264    }
265
266    impl From<gam_problem::EstimationError> for PirlsGpuLoopError {
267        fn from(error: gam_problem::EstimationError) -> Self {
268            Self::Geometry(error)
269        }
270    }
271
272    /// One-thread reduction over a p×p column-major Cholesky factor's
273    /// diagonal, computing `2·Σ ln(L[i,i])` device-side and writing a
274    /// single f64 into `out[0]`. The factor's lower-triangular Cholesky
275    /// has positive diagonal by construction, so no abs/clamp needed.
276    /// One thread is enough for the dominant p ≤ ~200 sizes; the cost was
277    /// previously a full p² download, so even a serial device sweep wins.
278    const CHOL_LOGDET_PTX_SOURCE: &str = r#"
279extern "C" __global__ void chol_logdet_col_major(
280    const double* __restrict__ factor,
281    int p,
282    double* __restrict__ out
283) {
284    if (threadIdx.x != 0 || blockIdx.x != 0) return;
285    double acc = 0.0;
286    long long pp = (long long)p;
287    for (long long i = 0; i < pp; ++i) {
288        acc += log(factor[i * pp + i]);
289    }
290    out[0] = 2.0 * acc;
291}
292"#;
293
294    static CHOL_LOGDET_CACHE: PtxModuleCache = PtxModuleCache::new();
295
296    /// When `p` is below this threshold the workspace uses the fused
297    /// `xtwx_lower` + `xtscore` + `symmetrize_lower` kernels and omits the
298    /// `n*p` `wx_dev` temporary entirely. For `p >= FUSED_XTWX_P_THRESHOLD`
299    /// the existing `ddgmm + dgemm` path is used.
300    const FUSED_XTWX_P_THRESHOLD: usize = 256;
301
302    /// NVRTC kernels for the fused path.
303    ///
304    /// `xtwx_lower`: one thread per lower-tri pair `(j,k)` with `j >= k`;
305    /// iterates over `n` rows, writes `A[j + k*p]` (col-major lower triangle).
306    ///
307    /// `xtscore`: one thread per `j`; writes `s[j] = sum_i score[i]*X[i,j]`.
308    ///
309    /// `symmetrize_lower`: one thread per strict-lower pair `(j,k)` with
310    /// `j > k`; copies `A[k + j*p] = A[j + k*p]` to fill the upper triangle.
311    const FUSED_XTWX_PTX_SOURCE: &str = concat!(
312        // xtwx_lower: enumerate lower triangle row-by-row.
313        // Row j has entries (j,0),(j,1),...,(j,j).
314        // Cumulative offset before row j = j*(j+1)/2.
315        // Unrank t -> j = floor((sqrt(8t+1)-1)/2), k = t - j*(j+1)/2.
316        // Output: A[j + k*p] in col-major for j >= k.
317        "extern \"C\" __global__ void xtwx_lower(",
318        "const double* __restrict__ X,",
319        "const double* __restrict__ w,",
320        "double* __restrict__ A,",
321        "int n, int p) {",
322        "int t=blockIdx.x*blockDim.x+threadIdx.x;",
323        "int np=p*(p+1)/2; if(t>=np)return;",
324        // j = floor((sqrt(8t+1)-1)/2); clamp for fp rounding
325        "int jv=(int)((__dsqrt_rn((double)(8*t+1))-1.0)*0.5);",
326        "while((long long)(jv+1)*(jv+2)/2<=t)jv++;",
327        "while(jv>0&&(long long)jv*(jv+1)/2>t)jv--;",
328        "int kv=t-(int)((long long)jv*(jv+1)/2);",
329        "double acc=0.0;",
330        "const double*Xj=X+(long long)jv*n;",
331        "const double*Xk=X+(long long)kv*n;",
332        "for(int i=0;i<n;++i)acc+=w[i]*Xj[i]*Xk[i];",
333        // col-major index: A[jv, kv] = A[jv + kv*p]
334        "A[jv+(long long)kv*p]=acc;}",
335        // xtscore: one thread per output index j
336        "extern \"C\" __global__ void xtscore(",
337        "const double* __restrict__ X,",
338        "const double* __restrict__ score,",
339        "double* __restrict__ s,",
340        "int n, int p) {",
341        "int j=blockIdx.x*blockDim.x+threadIdx.x;",
342        "if(j>=p)return;",
343        "double acc=0.0;",
344        "const double*Xj=X+(long long)j*n;",
345        "for(int i=0;i<n;++i)acc+=score[i]*Xj[i];",
346        "s[j]=acc;}",
347        // symmetrize_lower: strict lower pairs (j,k) with j>k.
348        // Enumerate row-by-row: row j=1 has entry (1,0); row j=2 has (2,0),(2,1); etc.
349        // Cumulative before row j: j*(j-1)/2.
350        // Unrank t -> j = floor((sqrt(8t+1)+1)/2), k = t - j*(j-1)/2.
351        "extern \"C\" __global__ void symmetrize_lower(",
352        "double* __restrict__ A, int p) {",
353        "int ns=p*(p-1)/2;",
354        "int t=blockIdx.x*blockDim.x+threadIdx.x;",
355        "if(t>=ns)return;",
356        // j = floor((sqrt(8t+1)+1)/2); clamp
357        "int jv=(int)((__dsqrt_rn((double)(8*t+1))+1.0)*0.5);",
358        "while((long long)jv*(jv-1)/2>t)jv--;",
359        "while((long long)(jv+1)*jv/2<=t)jv++;",
360        "int kv=t-(int)((long long)jv*(jv-1)/2);",
361        // A[kv, jv] = A[kv + jv*p] = A[jv + kv*p] (copy lower to upper)
362        "A[kv+(long long)jv*p]=A[jv+(long long)kv*p];}",
363    );
364
365    static FUSED_XTWX_CACHE: PtxModuleCache = PtxModuleCache::new();
366
367    impl PirlsGpuSharedData {
368        /// Upload `x` to the cached per-ordinal CUDA context and return a
369        /// Upload X_original, y, prior_w, and offset to the device once.
370        /// Returns a shared handle reused across all ρ / σ points.
371        pub(crate) fn upload_impl(
372            x: ArrayView2<'_, f64>,
373            y: ArrayView1<'_, f64>,
374            prior_w: ArrayView1<'_, f64>,
375            offset: ArrayView1<'_, f64>,
376        ) -> Result<Self, String> {
377            let (n, p) = x.dim();
378            if n == 0 || p == 0 {
379                return Err("empty design cannot be uploaded".to_string());
380            }
381            if y.len() != n || prior_w.len() != n || offset.len() != n {
382                return Err(format!(
383                    "y/prior_w/offset length mismatch (y={}, w={}, offset={}, n={n})",
384                    y.len(),
385                    prior_w.len(),
386                    offset.len()
387                ));
388            }
389            let (ctx, stream) = context_and_stream()?;
390            let x_col = to_col_major(&x);
391            let x_original_dev = pinned_htod(&stream, &x_col)?;
392            let y_dev = pinned_htod(&stream, y.as_slice().ok_or("y not contiguous")?)?;
393            let prior_w_dev =
394                pinned_htod(&stream, prior_w.as_slice().ok_or("prior_w not contiguous")?)?;
395            let offset_dev =
396                pinned_htod(&stream, offset.as_slice().ok_or("offset not contiguous")?)?;
397            // Synchronize the upload stream so all buffers are visible to
398            // every workspace we hand off to. Workspaces use independent
399            // streams; the uploads completed on the bootstrap stream above.
400            stream
401                .synchronize()
402                .map_err(|e| format!("cuda sync after model upload: {e}"))?;
403            Ok(Self {
404                ctx,
405                n,
406                p,
407                x_original_dev,
408                y_dev,
409                prior_w_dev,
410                offset_dev,
411            })
412        }
413    }
414
415    impl SigmaPirlsGpuWorkspace {
416        /// Allocate a workspace bound to a fresh non-default CUDA stream on
417        /// the shared context. cuBLAS and cuSOLVER handles are created with
418        /// that stream so every kernel issued through them is enqueued on
419        /// this workspace's stream, allowing concurrent overlap with peer
420        /// workspaces in the stream pool.
421        pub(crate) fn allocate_impl(shared: &PirlsGpuSharedData) -> Result<Self, String> {
422            let n = shared.n;
423            let p = shared.p;
424            let stream = shared
425                .ctx
426                .new_stream()
427                .map_err(|e| format!("cuda stream alloc: {e}"))?;
428            let blas = CudaBlas::new(stream.clone()).map_err(|e| format!("cublas init: {e}"))?;
429            let solver =
430                DnHandle::new(stream.clone()).map_err(|e| format!("cusolver init: {e}"))?;
431            let np = n.checked_mul(p).ok_or("X size overflow")?;
432            let pp = p.checked_mul(p).ok_or("H size overflow")?;
433            // Skip the n*p WX scratch when the fused kernels will be used.
434            let wx_dev = if p >= FUSED_XTWX_P_THRESHOLD {
435                Some(
436                    stream
437                        .alloc_zeros::<f64>(np)
438                        .map_err(|e| format!("cuda alloc WX: {e}"))?,
439                )
440            } else {
441                None
442            };
443            let w_dev = stream
444                .alloc_zeros::<f64>(n)
445                .map_err(|e| format!("cuda alloc W: {e}"))?;
446            let xtwx_dev = stream
447                .alloc_zeros::<f64>(pp)
448                .map_err(|e| format!("cuda alloc XtWX: {e}"))?;
449            let h_dev = stream
450                .alloc_zeros::<f64>(pp)
451                .map_err(|e| format!("cuda alloc H: {e}"))?;
452            let rhs_dev = stream
453                .alloc_zeros::<f64>(p)
454                .map_err(|e| format!("cuda alloc RHS: {e}"))?;
455            let penalty_dev = stream
456                .alloc_zeros::<f64>(pp)
457                .map_err(|e| format!("cuda alloc penalty: {e}"))?;
458            // Qs and scratch: p×p identity-initialized and p-vector zeros.
459            let mut qs_dev = stream
460                .alloc_zeros::<f64>(pp)
461                .map_err(|e| format!("cuda alloc Qs: {e}"))?;
462            // Initialize Qs to identity: diagonal = 1.0.
463            {
464                let mut qs_host = vec![0.0_f64; pp];
465                for i in 0..p {
466                    qs_host[i * p + i] = 1.0;
467                }
468                stream
469                    .memcpy_htod(&qs_host, &mut qs_dev)
470                    .map_err(|e| format!("init Qs identity: {e}"))?;
471            }
472            let qs_tmp_dev = stream
473                .alloc_zeros::<f64>(pp)
474                .map_err(|e| format!("cuda alloc Qs tmp: {e}"))?;
475            let beta_orig_dev = stream
476                .alloc_zeros::<f64>(p)
477                .map_err(|e| format!("cuda alloc beta_orig: {e}"))?;
478            let dir_orig_dev = stream
479                .alloc_zeros::<f64>(p)
480                .map_err(|e| format!("cuda alloc dir_orig: {e}"))?;
481            // Query the POTRF workspace size once using the actual p so we
482            // can size the persistent buffer. This is the only buffer-size
483            // query in the hot path — every Newton step reuses it.
484            let potrf_lwork_usize = potrf_query_lwork(&solver, &stream, p)?;
485            let potrf_lwork = i32::try_from(potrf_lwork_usize)
486                .map_err(|_| format!("potrf lwork {potrf_lwork_usize} exceeds i32"))?;
487            // Allocate at least 1 element so the device pointer is always
488            // valid; cuSOLVER accepts a zero-length workspace when lwork==0.
489            let alloc_len = potrf_lwork_usize.max(1);
490            let potrf_work_dev = stream
491                .alloc_zeros::<f64>(alloc_len)
492                .map_err(|e| format!("cuda alloc potrf workspace: {e}"))?;
493            let potrf_info_dev = stream
494                .alloc_zeros::<i32>(1)
495                .map_err(|e| format!("cuda alloc potrf info: {e}"))?;
496            let potrs_info_dev = stream
497                .alloc_zeros::<i32>(1)
498                .map_err(|e| format!("cuda alloc potrs info: {e}"))?;
499            Ok(Self {
500                stream,
501                blas,
502                solver,
503                wx_dev,
504                w_dev,
505                xtwx_dev,
506                h_dev,
507                rhs_dev,
508                penalty_dev,
509                qs_dev,
510                qs_tmp_dev,
511                beta_orig_dev,
512                dir_orig_dev,
513                potrf_work_dev,
514                potrf_lwork,
515                potrf_info_dev,
516                potrs_info_dev,
517                n,
518                p,
519            })
520        }
521    }
522
523    /// Upload a new `Qs` matrix (p×p, row-major host) to `ws.qs_dev`.
524    /// Call once per ρ / σ point before calling `pirls_loop` or any step
525    /// function. When no reparameterisation is active, pass the identity.
526    pub(super) fn upload_qs(
527        ws: &mut SigmaPirlsGpuWorkspace,
528        qs: ArrayView2<'_, f64>,
529    ) -> Result<(), String> {
530        let p = ws.p;
531        if qs.dim() != (p, p) {
532            return Err(format!("upload_qs: Qs shape {:?} != ({p},{p})", qs.dim()));
533        }
534        let qs_col = to_col_major(&qs);
535        ws.stream
536            .memcpy_htod(qs_col.as_ref(), &mut ws.qs_dev)
537            .map_err(|e| format!("upload Qs: {e}"))
538    }
539
540    /// Upload an identity `Qs` (no reparameterisation) for the current ρ point.
541    pub(super) fn upload_qs_identity(ws: &mut SigmaPirlsGpuWorkspace) -> Result<(), String> {
542        let p = ws.p;
543        let pp = p * p;
544        let mut qs_host = vec![0.0_f64; pp];
545        for i in 0..p {
546            qs_host[i * p + i] = 1.0;
547        }
548        ws.stream
549            .memcpy_htod(&qs_host, &mut ws.qs_dev)
550            .map_err(|e| format!("upload Qs identity: {e}"))
551    }
552
553    /// Apply one fp64 iterative-refinement correction to a Newton step solve.
554    ///
555    /// Compute `r = g − H_step·x` (host, p-vector). When `p ≥ REFINEMENT_MIN_P`
556    /// and `‖r‖/‖g‖ > REFINEMENT_TOL`, apply one POTRS correction and return
557    /// `x + e`. Returns `direction_raw` unchanged when `p` is too small, the
558    /// residual is already tight, or `‖g‖ = 0`.
559    ///
560    /// `H_step·x = penalized_hessian·x + step_lm_delta·x`.
561    fn newton_step_refine_once(
562        solver: &cudarc::cusolver::DnHandle,
563        stream: &std::sync::Arc<cudarc::driver::CudaStream>,
564        p: usize,
565        chol_factor_dev: &CudaSlice<f64>,
566        rhs_dev: &mut CudaSlice<f64>,
567        potrs_info_dev: &mut CudaSlice<i32>,
568        mut direction_raw: Vec<f64>,
569        g: &[f64],
570        penalized_hessian: &ndarray::Array2<f64>,
571        step_lm_delta: f64,
572    ) -> Result<Vec<f64>, String> {
573        use gam_gpu::policy::GpuDispatchPolicy;
574        if p < GpuDispatchPolicy::REFINEMENT_MIN_P {
575            return Ok(direction_raw);
576        }
577        let norm_g = g.iter().map(|v| v * v).sum::<f64>().sqrt();
578        if norm_g == 0.0 {
579            return Ok(direction_raw);
580        }
581        let hx: Vec<f64> = (0..p)
582            .map(|i| {
583                penalized_hessian
584                    .row(i)
585                    .iter()
586                    .zip(direction_raw.iter())
587                    .map(|(hij, xj)| hij * xj)
588                    .sum::<f64>()
589                    + step_lm_delta * direction_raw[i]
590            })
591            .collect();
592        let residual: Vec<f64> = g.iter().zip(hx.iter()).map(|(gi, hxi)| gi - hxi).collect();
593        let rel_res = residual.iter().map(|v| v * v).sum::<f64>().sqrt() / norm_g;
594        if rel_res <= GpuDispatchPolicy::REFINEMENT_TOL {
595            return Ok(direction_raw);
596        }
597        stream
598            .memcpy_htod(&residual, rhs_dev)
599            .map_err(|e| format!("upload residual: {e}"))?;
600        potrs_in_place_reuse(
601            solver,
602            stream,
603            p,
604            1,
605            chol_factor_dev,
606            rhs_dev,
607            potrs_info_dev,
608        )?;
609        let correction = stream
610            .clone_dtoh(rhs_dev)
611            .map_err(|e| format!("download correction: {e}"))?;
612        check_deferred_potrs_info(stream, potrs_info_dev)?;
613        for (xi, ei) in direction_raw.iter_mut().zip(correction.iter()) {
614            *xi += ei;
615        }
616        Ok(direction_raw)
617    }
618
619    /// Drive one PIRLS Newton step on the workspace's CUDA stream.
620    ///
621    /// Build `H = XᵀWX + S + λI`, Cholesky-factor it, solve `H·d = g`,
622    /// return `(H, d, log|H|)`. `input.gradient` is the full descent-direction
623    /// RHS `Xᵀscore − S·β + linear_shift` — the caller is responsible for
624    /// assembling the corrected RHS before calling this function. No negation
625    /// is applied; the returned `direction = H⁻¹·g` is the descent step δ
626    /// directly (#257). The difference vs the one-shot [`solve_step`] is
627    /// purely the execution model: no context creation, no handle creation,
628    /// no design-matrix upload, no per-step buffer allocations.
629    pub(super) fn solve_step_on_stream(
630        shared: &PirlsGpuSharedData,
631        ws: &mut SigmaPirlsGpuWorkspace,
632        input: PirlsStepStreamInput<'_>,
633    ) -> Result<PirlsGpuStep, String> {
634        let n = shared.n;
635        let p = shared.p;
636        if ws.n != n || ws.p != p {
637            return Err(format!(
638                "workspace shape ({}, {}) does not match shared design ({n}, {p})",
639                ws.n, ws.p
640            ));
641        }
642        if input.weights.len() != n {
643            return Err(format!(
644                "weights length {} does not match rows {n}",
645                input.weights.len()
646            ));
647        }
648        if input.penalty_hessian.dim() != (p, p) {
649            return Err(format!(
650                "penalty Hessian shape {:?} does not match p={p}",
651                input.penalty_hessian.dim()
652            ));
653        }
654        if input.gradient.len() != p {
655            return Err(format!(
656                "gradient length {} does not match p={p}",
657                input.gradient.len()
658            ));
659        }
660
661        // Upload per-step weights into the persistent W buffer.
662        let w_slice = input
663            .weights
664            .as_slice()
665            .ok_or("weights must be contiguous")?;
666        ws.stream
667            .memcpy_htod(w_slice, &mut ws.w_dev)
668            .map_err(|e| format!("upload W: {e}"))?;
669
670        // Compute XᵀWX into ws.xtwx_dev.  Two paths:
671        // Fused (p < FUSED_XTWX_P_THRESHOLD): row-sweep kernels, no n*p temp.
672        // Fallback (p >= FUSED_XTWX_P_THRESHOLD): ddgmm + dgemm via wx_dev.
673        let n_i = to_i32(n)?;
674        let p_i = to_i32(p)?;
675        if let Some(ref mut wx_dev) = ws.wx_dev {
676            left_scale_rows(
677                &ws.blas,
678                &ws.stream,
679                n,
680                p,
681                &shared.x_original_dev,
682                &mut ws.w_dev,
683                wx_dev,
684            )?;
685            let cfg = GemmConfig::<f64> {
686                transa: cublasOperation_t::CUBLAS_OP_T,
687                transb: cublasOperation_t::CUBLAS_OP_N,
688                m: p_i,
689                n: p_i,
690                k: n_i,
691                alpha: 1.0,
692                lda: n_i,
693                ldb: n_i,
694                beta: 0.0,
695                ldc: p_i,
696            };
697            // SAFETY: validated i32 dims; shared.x_original_dev and wx_dev are n*p
698            // f64 col-major; ws.xtwx_dev is the p*p output.
699            unsafe {
700                ws.blas
701                    .gemm(cfg, &shared.x_original_dev, wx_dev, &mut ws.xtwx_dev)
702            }
703            .map_err(|e| format!("cublas dgemm XtWX: {e}"))?;
704        } else {
705            launch_xtwx_lower(
706                &ws.stream,
707                &shared.ctx,
708                n,
709                p,
710                &shared.x_original_dev,
711                &ws.w_dev,
712                &mut ws.xtwx_dev,
713            )?;
714            launch_symmetrize_lower(&ws.stream, &shared.ctx, p, &mut ws.xtwx_dev)?;
715        }
716
717        // Upload S + step_lm_lambda·I for the Newton solve (LM damping only).
718        let penalty_step = penalty_with_ridge(input.penalty_hessian, input.step_lm_lambda);
719        let penalty_step_view = penalty_step.view();
720        let penalty_step_col = to_col_major(&penalty_step_view);
721        ws.stream
722            .memcpy_htod(penalty_step_col.as_ref(), &mut ws.penalty_dev)
723            .map_err(|e| format!("upload penalty: {e}"))?;
724
725        // Apply Qs rotation: H_xtx = Qsᵀ · XᵀWX · Qs (two p×p gemms).
726        // Matches solve_step_on_stream_device_inplace (#269 resident-X arch):
727        // X_original stays device-resident, Qs rotates into transformed frame.
728        {
729            let cfg_aq = GemmConfig::<f64> {
730                transa: cublasOperation_t::CUBLAS_OP_N,
731                transb: cublasOperation_t::CUBLAS_OP_N,
732                m: p_i,
733                n: p_i,
734                k: p_i,
735                alpha: 1.0,
736                lda: p_i,
737                ldb: p_i,
738                beta: 0.0,
739                ldc: p_i,
740            };
741            // SAFETY: xtwx_dev and qs_dev p*p col-major; qs_tmp_dev p*p output.
742            unsafe {
743                ws.blas
744                    .gemm(cfg_aq, &ws.xtwx_dev, &ws.qs_dev, &mut ws.qs_tmp_dev)
745            }
746            .map_err(|e| format!("dgemm A·Qs (host-input step): {e}"))?;
747        }
748        {
749            let cfg_qt = GemmConfig::<f64> {
750                transa: cublasOperation_t::CUBLAS_OP_T,
751                transb: cublasOperation_t::CUBLAS_OP_N,
752                m: p_i,
753                n: p_i,
754                k: p_i,
755                alpha: 1.0,
756                lda: p_i,
757                ldb: p_i,
758                beta: 0.0,
759                ldc: p_i,
760            };
761            // SAFETY: qs_dev p*p (transposed); qs_tmp_dev p*p; h_dev p*p output.
762            unsafe {
763                ws.blas
764                    .gemm(cfg_qt, &ws.qs_dev, &ws.qs_tmp_dev, &mut ws.h_dev)
765            }
766            .map_err(|e| format!("dgemm Qsᵀ·A·Qs (host-input step): {e}"))?;
767        }
768        // H_step = Qsᵀ·XᵀWX·Qs + (S + step_lm_lambda·I).
769        geam_add_inplace(&ws.blas, &ws.stream, p, &mut ws.h_dev, &ws.penalty_dev)?;
770
771        // Upload gradient into the persistent RHS buffer.
772        // `input.gradient` is already in transformed coordinates (Qsᵀ-projected
773        // by the caller), so no additional rotation is needed here.
774        let g_slice = input
775            .gradient
776            .as_slice()
777            .ok_or("gradient must be contiguous")?;
778        ws.stream
779            .memcpy_htod(g_slice, &mut ws.rhs_dev)
780            .map_err(|e| format!("upload gradient: {e}"))?;
781
782        // Exported penalised Hessian: H_final = Qsᵀ·XᵀWX·Qs + S + objective_ridge·I.
783        // Apply Qs rotation host-side on the downloaded XᵀWX so LM damping
784        // never contaminates exported EDF / REML curvature / RidgePassport.
785        let xtwx_col = ws
786            .stream
787            .clone_dtoh(&ws.xtwx_dev)
788            .map_err(|e| format!("download XᵀWX (host-input step): {e}"))?;
789        let xtwx_host = from_col_major(&xtwx_col, p, p).ok_or("XᵀWX layout conversion failed")?;
790        let qs_col = ws
791            .stream
792            .clone_dtoh(&ws.qs_dev)
793            .map_err(|e| format!("download Qs (host-input step): {e}"))?;
794        let qs_host =
795            from_col_major(&qs_col, p, p).ok_or("Qs layout conversion failed (host-input step)")?;
796        let tmp_aq = xtwx_host.dot(&qs_host);
797        let h_rotated = qs_host.t().dot(&tmp_aq);
798        let penalty_export = penalty_with_ridge(input.penalty_hessian, input.objective_ridge);
799        let penalized_hessian = h_rotated + &penalty_export;
800
801        // Factor + solve in place on the stream using pre-allocated workspace
802        // and info buffers — no per-step allocation, no per-step info download.
803        potrf_in_place_reuse(
804            &ws.solver,
805            &ws.stream,
806            p,
807            ws.potrf_lwork,
808            &mut ws.h_dev,
809            &mut ws.potrf_work_dev,
810            &mut ws.potrf_info_dev,
811        )?;
812        potrs_in_place_reuse(
813            &ws.solver,
814            &ws.stream,
815            p,
816            1,
817            &ws.h_dev,
818            &mut ws.rhs_dev,
819            &mut ws.potrs_info_dev,
820        )?;
821
822        // Logdet device-side: reduces the previous p² Cholesky-factor
823        // download to a single f64 download. Stage 2's "no per-iteration
824        // host round-trip" budget keeps the p² factor on the device.
825        let logdet = cholesky_logdet_device(&ws.stream, &shared.ctx, p, &ws.h_dev)?;
826
827        // Direction: d = H⁻¹ g (no negation; g is the full corrected RHS, #257).
828        let direction_raw = ws
829            .stream
830            .clone_dtoh(&ws.rhs_dev)
831            .map_err(|e| format!("download direction: {e}"))?;
832        // Check deferred POTRF/POTRS info after the direction download
833        // (which already syncs the stream). Single host round-trip for both
834        // info scalars at end-of-step rather than one per cuSOLVER call.
835        check_deferred_potrf_info(&ws.stream, &ws.potrf_info_dev)?;
836        check_deferred_potrs_info(&ws.stream, &ws.potrs_info_dev)?;
837
838        // Iterative refinement on the Qs-rotated system.
839        // penalized_hessian = Qsᵀ·XtWX·Qs + S + objective_ridge·I.
840        // H_step = penalized_hessian + (step_lm_lambda − objective_ridge)·I.
841        let lm_ridge_delta = input.step_lm_lambda - input.objective_ridge;
842        let direction_raw = newton_step_refine_once(
843            &ws.solver,
844            &ws.stream,
845            p,
846            &ws.h_dev,
847            &mut ws.rhs_dev,
848            &mut ws.potrs_info_dev,
849            direction_raw,
850            g_slice,
851            &penalized_hessian,
852            lm_ridge_delta,
853        )?;
854
855        // No negation: `input.gradient` is the full descent-direction RHS
856        // `Xᵀscore − S·β + linear_shift`; solving H·δ = rhs gives δ directly.
857        let direction = Array1::from_vec(direction_raw);
858
859        Ok(PirlsGpuStep {
860            penalized_hessian,
861            direction,
862            logdet,
863        })
864    }
865
866    /// Stage 3.2 device-input PIRLS Newton step.
867    ///
868    /// Identical math to [`solve_step_on_stream`] but reads `w_solver`
869    /// and `grad_eta` straight from device buffers populated by the
870    /// device-side row-reweight kernel (no host upload of weights or
871    /// gradient). Only the penalty matrix still crosses the host
872    /// boundary because the outer REML loop updates Sλ + LM ridge
873    /// between PIRLS steps; the penalty is p×p which is independent of
874    /// n, so for large-scale n it is a negligible transfer.
875    ///
876    /// Outputs match `solve_step_on_stream`: returns the assembled
877    /// penalised Hessian, the Newton descent direction `δ = H⁻¹·rhs`
878    /// where `rhs = Xᵀ·score − S·β + linear_shift` (no negation, #257),
879    /// and the log-determinant computed via the device-side
880    /// `chol_logdet_col_major` kernel.
881    pub(super) fn solve_step_on_stream_device(
882        shared: &PirlsGpuSharedData,
883        ws: &mut SigmaPirlsGpuWorkspace,
884        input: PirlsStepStreamDeviceInput<'_, '_>,
885    ) -> Result<PirlsGpuStep, String> {
886        let n = shared.n;
887        let p = shared.p;
888        if ws.n != n || ws.p != p {
889            return Err(format!(
890                "workspace shape ({}, {}) does not match shared design ({n}, {p})",
891                ws.n, ws.p
892            ));
893        }
894        if input.w_solver_dev.len() != n {
895            return Err(format!(
896                "w_solver_dev length {} does not match n={n}",
897                input.w_solver_dev.len()
898            ));
899        }
900        if input.grad_eta_dev.len() != n {
901            return Err(format!(
902                "grad_eta_dev length {} does not match n={n}",
903                input.grad_eta_dev.len()
904            ));
905        }
906        if input.penalty_hessian.dim() != (p, p) {
907            return Err(format!(
908                "penalty Hessian shape {:?} does not match p={p}",
909                input.penalty_hessian.dim()
910            ));
911        }
912
913        // Compute XᵀWX and Xᵀ·score.  Fused path (p < threshold): no n*p WX.
914        // Fallback (p >= threshold): ddgmm + dgemm + gemv via wx_dev_fb.
915        let n_i = to_i32(n)?;
916        let p_i = to_i32(p)?;
917        if let Some(ref mut wx_dev_fb) = ws.wx_dev {
918            // Large-p fallback.
919            left_scale_rows_borrowed(
920                &ws.blas,
921                &ws.stream,
922                n,
923                p,
924                &shared.x_original_dev,
925                input.w_solver_dev,
926                wx_dev_fb,
927            )?;
928            let gemm_cfg = GemmConfig::<f64> {
929                transa: cublasOperation_t::CUBLAS_OP_T,
930                transb: cublasOperation_t::CUBLAS_OP_N,
931                m: p_i,
932                n: p_i,
933                k: n_i,
934                alpha: 1.0,
935                lda: n_i,
936                ldb: n_i,
937                beta: 0.0,
938                ldc: p_i,
939            };
940            // SAFETY: validated dims; shared.x_original_dev and wx_dev_fb are n*p
941            // f64 col-major; ws.xtwx_dev is p*p; all on ws.stream.
942            unsafe {
943                ws.blas.gemm(
944                    gemm_cfg,
945                    &shared.x_original_dev,
946                    wx_dev_fb,
947                    &mut ws.xtwx_dev,
948                )
949            }
950            .map_err(|e| format!("cublas dgemm XtWX (device-input): {e}"))?;
951            let penalty_step = penalty_with_ridge(input.penalty_hessian, input.step_lm_lambda);
952            let penalty_step_col = to_col_major(&penalty_step);
953            ws.stream
954                .memcpy_htod(penalty_step_col.as_ref(), &mut ws.penalty_dev)
955                .map_err(|e| format!("upload penalty (device-input): {e}"))?;
956            // Qs rotation on H: tmp = XᵀWX · Qs, then h_dev = Qsᵀ · tmp.
957            {
958                let cfg_aq = GemmConfig::<f64> {
959                    transa: cublasOperation_t::CUBLAS_OP_N,
960                    transb: cublasOperation_t::CUBLAS_OP_N,
961                    m: p_i,
962                    n: p_i,
963                    k: p_i,
964                    alpha: 1.0,
965                    lda: p_i,
966                    ldb: p_i,
967                    beta: 0.0,
968                    ldc: p_i,
969                };
970                // SAFETY: xtwx_dev and qs_dev p*p col-major; qs_tmp_dev p*p output.
971                unsafe {
972                    ws.blas
973                        .gemm(cfg_aq, &ws.xtwx_dev, &ws.qs_dev, &mut ws.qs_tmp_dev)
974                }
975                .map_err(|e| format!("dgemm A·Qs (device-input large-p): {e}"))?;
976            }
977            {
978                let cfg_qt = GemmConfig::<f64> {
979                    transa: cublasOperation_t::CUBLAS_OP_T,
980                    transb: cublasOperation_t::CUBLAS_OP_N,
981                    m: p_i,
982                    n: p_i,
983                    k: p_i,
984                    alpha: 1.0,
985                    lda: p_i,
986                    ldb: p_i,
987                    beta: 0.0,
988                    ldc: p_i,
989                };
990                // SAFETY: qs_dev p*p (transposed); qs_tmp_dev p*p; h_dev p*p output.
991                unsafe {
992                    ws.blas
993                        .gemm(cfg_qt, &ws.qs_dev, &ws.qs_tmp_dev, &mut ws.h_dev)
994                }
995                .map_err(|e| format!("dgemm Qsᵀ·A·Qs (device-input large-p): {e}"))?;
996            }
997            geam_add_inplace(&ws.blas, &ws.stream, p, &mut ws.h_dev, &ws.penalty_dev)?;
998            let gemv_cfg = GemvConfig::<f64> {
999                trans: cublasOperation_t::CUBLAS_OP_T,
1000                m: n_i,
1001                n: p_i,
1002                alpha: 1.0,
1003                lda: n_i,
1004                incx: 1,
1005                beta: 0.0,
1006                incy: 1,
1007            };
1008            // SAFETY: shared.x_original_dev n*p col-major; grad_eta_dev length n; rhs_dev length p.
1009            unsafe {
1010                ws.blas.gemv(
1011                    gemv_cfg,
1012                    &shared.x_original_dev,
1013                    input.grad_eta_dev,
1014                    &mut ws.rhs_dev,
1015                )
1016            }
1017            .map_err(|e| format!("cublas dgemv Xtg (device-input): {e}"))?;
1018        } else {
1019            // Fused path: row-sweep kernels, no n*p WX buffer.
1020            launch_xtwx_lower(
1021                &ws.stream,
1022                &shared.ctx,
1023                n,
1024                p,
1025                &shared.x_original_dev,
1026                input.w_solver_dev,
1027                &mut ws.xtwx_dev,
1028            )?;
1029            launch_symmetrize_lower(&ws.stream, &shared.ctx, p, &mut ws.xtwx_dev)?;
1030            launch_xtscore(
1031                &ws.stream,
1032                &shared.ctx,
1033                n,
1034                p,
1035                &shared.x_original_dev,
1036                input.grad_eta_dev,
1037                &mut ws.rhs_dev,
1038            )?;
1039            // Qs rotation on H: tmp = XᵀWX · Qs, then h_dev = Qsᵀ · tmp.
1040            {
1041                let cfg_aq = GemmConfig::<f64> {
1042                    transa: cublasOperation_t::CUBLAS_OP_N,
1043                    transb: cublasOperation_t::CUBLAS_OP_N,
1044                    m: p_i,
1045                    n: p_i,
1046                    k: p_i,
1047                    alpha: 1.0,
1048                    lda: p_i,
1049                    ldb: p_i,
1050                    beta: 0.0,
1051                    ldc: p_i,
1052                };
1053                // SAFETY: xtwx_dev and qs_dev p*p col-major; qs_tmp_dev p*p output.
1054                unsafe {
1055                    ws.blas
1056                        .gemm(cfg_aq, &ws.xtwx_dev, &ws.qs_dev, &mut ws.qs_tmp_dev)
1057                }
1058                .map_err(|e| format!("dgemm A·Qs (device-input fused): {e}"))?;
1059            }
1060            {
1061                let cfg_qt = GemmConfig::<f64> {
1062                    transa: cublasOperation_t::CUBLAS_OP_T,
1063                    transb: cublasOperation_t::CUBLAS_OP_N,
1064                    m: p_i,
1065                    n: p_i,
1066                    k: p_i,
1067                    alpha: 1.0,
1068                    lda: p_i,
1069                    ldb: p_i,
1070                    beta: 0.0,
1071                    ldc: p_i,
1072                };
1073                // SAFETY: qs_dev p*p (transposed); qs_tmp_dev p*p; h_dev p*p output.
1074                unsafe {
1075                    ws.blas
1076                        .gemm(cfg_qt, &ws.qs_dev, &ws.qs_tmp_dev, &mut ws.h_dev)
1077                }
1078                .map_err(|e| format!("dgemm Qsᵀ·A·Qs (device-input fused): {e}"))?;
1079            }
1080            let penalty_step = penalty_with_ridge(input.penalty_hessian, input.step_lm_lambda);
1081            let penalty_step_col = to_col_major(&penalty_step);
1082            ws.stream
1083                .memcpy_htod(penalty_step_col.as_ref(), &mut ws.penalty_dev)
1084                .map_err(|e| format!("upload penalty (fused device-input): {e}"))?;
1085            geam_add_inplace(&ws.blas, &ws.stream, p, &mut ws.h_dev, &ws.penalty_dev)?;
1086        }
1087
1088        // Apply rhs correction BEFORE the solve:
1089        //   rhs = Qsᵀ·(Xᵀ·score) − S·β + linear_shift  (#257, #260, #269).
1090        // First project X_origᵀ·score through Qsᵀ (p×p gemv on device), then
1091        // apply the S·β correction host-side and re-upload.
1092        {
1093            // Qsᵀ · rhs_dev (= Xᵀ·score) → beta_orig_dev (scratch p-vector).
1094            let cfg_qts = GemvConfig::<f64> {
1095                trans: cublasOperation_t::CUBLAS_OP_T,
1096                m: p_i,
1097                n: p_i,
1098                alpha: 1.0,
1099                lda: p_i,
1100                incx: 1,
1101                beta: 0.0,
1102                incy: 1,
1103            };
1104            // SAFETY: qs_dev p*p (transposed); rhs_dev length p; beta_orig_dev length p.
1105            unsafe {
1106                ws.blas
1107                    .gemv(cfg_qts, &ws.qs_dev, &ws.rhs_dev, &mut ws.beta_orig_dev)
1108            }
1109            .map_err(|e| format!("dgemv Qsᵀ·score (device-input): {e}"))?;
1110            // Swap: rhs_dev ← beta_orig_dev (now holds Qsᵀ·Xᵀ·score).
1111            ws.stream
1112                .memcpy_dtod(&ws.beta_orig_dev, &mut ws.rhs_dev)
1113                .map_err(|e| format!("d2d Qsᵀ·score→rhs (device-input): {e}"))?;
1114            // Download rhs and β; apply penalty correction host-side.
1115            let rhs_raw = ws
1116                .stream
1117                .clone_dtoh(&ws.rhs_dev)
1118                .map_err(|e| format!("download Qsᵀscore (device-input): {e}"))?;
1119            let beta_raw = ws
1120                .stream
1121                .clone_dtoh(input.beta_dev)
1122                .map_err(|e| format!("download beta (device-input): {e}"))?;
1123            let mut rhs_host = Array1::from_vec(rhs_raw);
1124            let beta_host = Array1::from_vec(beta_raw);
1125            let s_beta = input.penalty_hessian.dot(&beta_host);
1126            rhs_host -= &s_beta;
1127            rhs_host += &input.linear_shift;
1128            ws.stream
1129                .memcpy_htod(
1130                    rhs_host
1131                        .as_slice()
1132                        .ok_or("rhs_host not contiguous (device-input correction)")?,
1133                    &mut ws.rhs_dev,
1134                )
1135                .map_err(|e| format!("re-upload corrected rhs (device-input): {e}"))?;
1136        }
1137
1138        // Exported penalised Hessian: H_final = Qsᵀ·XᵀWX·Qs + S + objective_ridge·I.
1139        // Apply Qs rotation host-side on the downloaded XᵀWX so LM damping
1140        // never contaminates exported EDF / REML curvature / RidgePassport.
1141        let xtwx_col = ws
1142            .stream
1143            .clone_dtoh(&ws.xtwx_dev)
1144            .map_err(|e| format!("download XᵀWX (device-input): {e}"))?;
1145        let xtwx_host = from_col_major(&xtwx_col, p, p)
1146            .ok_or("XᵀWX layout conversion failed (device-input)")?;
1147        let qs_col = ws
1148            .stream
1149            .clone_dtoh(&ws.qs_dev)
1150            .map_err(|e| format!("download Qs (device-input): {e}"))?;
1151        let qs_host =
1152            from_col_major(&qs_col, p, p).ok_or("Qs layout conversion failed (device-input)")?;
1153        let tmp_aq = xtwx_host.dot(&qs_host);
1154        let h_rotated = qs_host.t().dot(&tmp_aq);
1155        let penalty_export = penalty_with_ridge(input.penalty_hessian, input.objective_ridge);
1156        let penalized_hessian = h_rotated + &penalty_export;
1157
1158        // Factor + solve in place on the stream using pre-allocated workspace
1159        // and info buffers — no per-step allocation, no per-step info download.
1160        potrf_in_place_reuse(
1161            &ws.solver,
1162            &ws.stream,
1163            p,
1164            ws.potrf_lwork,
1165            &mut ws.h_dev,
1166            &mut ws.potrf_work_dev,
1167            &mut ws.potrf_info_dev,
1168        )?;
1169        potrs_in_place_reuse(
1170            &ws.solver,
1171            &ws.stream,
1172            p,
1173            1,
1174            &ws.h_dev,
1175            &mut ws.rhs_dev,
1176            &mut ws.potrs_info_dev,
1177        )?;
1178
1179        let logdet = cholesky_logdet_device(&ws.stream, &shared.ctx, p, &ws.h_dev)?;
1180
1181        let direction_raw = ws
1182            .stream
1183            .clone_dtoh(&ws.rhs_dev)
1184            .map_err(|e| format!("download direction (device-input): {e}"))?;
1185        // Check deferred POTRF/POTRS info after the direction download
1186        // (which already syncs the stream). Single host round-trip for both
1187        // info scalars at end-of-step rather than one per cuSOLVER call.
1188        check_deferred_potrf_info(&ws.stream, &ws.potrf_info_dev)?;
1189        check_deferred_potrs_info(&ws.stream, &ws.potrs_info_dev)?;
1190        // No negation: rhs = Xᵀscore − Sβ + linear_shift already gives the
1191        // descent direction δ = H⁻¹·rhs directly (#257).
1192        let direction = Array1::from_vec(direction_raw);
1193
1194        Ok(PirlsGpuStep {
1195            penalized_hessian,
1196            direction,
1197            logdet,
1198        })
1199    }
1200
1201    /// In-place Newton step: rhs = Xᵀ·score − S·β + linear_shift (#257, #260).
1202    ///
1203    /// Solves H·δ = rhs (H = XᵀWX + S + step_lm_lambda·I). On return
1204    /// `ws.rhs_dev` holds the Newton descent direction δ (not negated).
1205    /// The loop copies `ws.rhs_dev` to `direction_dev` via `memcpy_dtod`.
1206    ///
1207    /// On return `ws.h_dev` holds the Cholesky factor; rebuild with
1208    /// `rebuild_h_final` to get the exported penalised Hessian.
1209    ///
1210    /// Returns `logdet = log|H|` computed device-side.
1211    pub(super) fn solve_step_on_stream_device_inplace(
1212        shared: &PirlsGpuSharedData,
1213        ws: &mut SigmaPirlsGpuWorkspace,
1214        input: PirlsStepStreamDeviceInput<'_, '_>,
1215    ) -> Result<f64, String> {
1216        let n = shared.n;
1217        let p = shared.p;
1218        if ws.n != n || ws.p != p {
1219            return Err(format!(
1220                "workspace shape ({}, {}) does not match shared design ({n}, {p})",
1221                ws.n, ws.p
1222            ));
1223        }
1224        if input.w_solver_dev.len() != n {
1225            return Err(format!(
1226                "w_solver_dev length {} does not match n={n}",
1227                input.w_solver_dev.len()
1228            ));
1229        }
1230        if input.grad_eta_dev.len() != n {
1231            return Err(format!(
1232                "grad_eta_dev length {} does not match n={n}",
1233                input.grad_eta_dev.len()
1234            ));
1235        }
1236        if input.penalty_hessian.dim() != (p, p) {
1237            return Err(format!(
1238                "penalty Hessian shape {:?} does not match p={p}",
1239                input.penalty_hessian.dim()
1240            ));
1241        }
1242
1243        if input.linear_shift.len() != p {
1244            return Err(format!(
1245                "linear_shift length {} does not match p={p}",
1246                input.linear_shift.len()
1247            ));
1248        }
1249        let n_i = to_i32(n)?;
1250        let p_i = to_i32(p)?;
1251
1252        // Step 1: A = X_origᵀ diag(w_solver) X_orig → ws.xtwx_dev.
1253        //         score_p = X_origᵀ grad_eta → ws.rhs_dev.
1254        if let Some(ref mut wx_dev_ib) = ws.wx_dev {
1255            // Large-p path: ddgmm then dgemm, then gemv.
1256            left_scale_rows_borrowed(
1257                &ws.blas,
1258                &ws.stream,
1259                n,
1260                p,
1261                &shared.x_original_dev,
1262                input.w_solver_dev,
1263                wx_dev_ib,
1264            )?;
1265            let cfg_xtx = GemmConfig::<f64> {
1266                transa: cublasOperation_t::CUBLAS_OP_T,
1267                transb: cublasOperation_t::CUBLAS_OP_N,
1268                m: p_i,
1269                n: p_i,
1270                k: n_i,
1271                alpha: 1.0,
1272                lda: n_i,
1273                ldb: n_i,
1274                beta: 0.0,
1275                ldc: p_i,
1276            };
1277            // SAFETY: x_original_dev and wx_dev_ib n*p col-major; xtwx_dev p*p; ws.stream.
1278            unsafe {
1279                ws.blas
1280                    .gemm(cfg_xtx, &shared.x_original_dev, wx_dev_ib, &mut ws.xtwx_dev)
1281            }
1282            .map_err(|e| format!("dgemm XtWX inplace (large-p): {e}"))?;
1283            let cfg_xts = GemvConfig::<f64> {
1284                trans: cublasOperation_t::CUBLAS_OP_T,
1285                m: n_i,
1286                n: p_i,
1287                alpha: 1.0,
1288                lda: n_i,
1289                incx: 1,
1290                beta: 0.0,
1291                incy: 1,
1292            };
1293            // SAFETY: x_original_dev n*p col-major; grad_eta_dev length n; rhs_dev length p.
1294            unsafe {
1295                ws.blas.gemv(
1296                    cfg_xts,
1297                    &shared.x_original_dev,
1298                    input.grad_eta_dev,
1299                    &mut ws.rhs_dev,
1300                )
1301            }
1302            .map_err(|e| format!("dgemv Xᵀ·score inplace (large-p): {e}"))?;
1303        } else {
1304            // Fused path: row-sweep kernels, no n*p WX buffer.
1305            launch_xtwx_lower(
1306                &ws.stream,
1307                &shared.ctx,
1308                n,
1309                p,
1310                &shared.x_original_dev,
1311                input.w_solver_dev,
1312                &mut ws.xtwx_dev,
1313            )?;
1314            launch_symmetrize_lower(&ws.stream, &shared.ctx, p, &mut ws.xtwx_dev)?;
1315            launch_xtscore(
1316                &ws.stream,
1317                &shared.ctx,
1318                n,
1319                p,
1320                &shared.x_original_dev,
1321                input.grad_eta_dev,
1322                &mut ws.rhs_dev,
1323            )?;
1324        }
1325
1326        // Step 2: H_xtx = Qsᵀ A Qs  (two p×p gemms).
1327        //   tmp = A · Qs → ws.qs_tmp_dev.
1328        {
1329            let cfg_aq = GemmConfig::<f64> {
1330                transa: cublasOperation_t::CUBLAS_OP_N,
1331                transb: cublasOperation_t::CUBLAS_OP_N,
1332                m: p_i,
1333                n: p_i,
1334                k: p_i,
1335                alpha: 1.0,
1336                lda: p_i,
1337                ldb: p_i,
1338                beta: 0.0,
1339                ldc: p_i,
1340            };
1341            // SAFETY: xtwx_dev and qs_dev p*p col-major; qs_tmp_dev p*p output.
1342            unsafe {
1343                ws.blas
1344                    .gemm(cfg_aq, &ws.xtwx_dev, &ws.qs_dev, &mut ws.qs_tmp_dev)
1345            }
1346            .map_err(|e| format!("dgemm A·Qs inplace: {e}"))?;
1347        }
1348        //   H_xtx = Qsᵀ · tmp → ws.h_dev.
1349        {
1350            let cfg_qt = GemmConfig::<f64> {
1351                transa: cublasOperation_t::CUBLAS_OP_T,
1352                transb: cublasOperation_t::CUBLAS_OP_N,
1353                m: p_i,
1354                n: p_i,
1355                k: p_i,
1356                alpha: 1.0,
1357                lda: p_i,
1358                ldb: p_i,
1359                beta: 0.0,
1360                ldc: p_i,
1361            };
1362            // SAFETY: qs_dev p*p (transposed); qs_tmp_dev p*p; h_dev p*p output.
1363            unsafe {
1364                ws.blas
1365                    .gemm(cfg_qt, &ws.qs_dev, &ws.qs_tmp_dev, &mut ws.h_dev)
1366            }
1367            .map_err(|e| format!("dgemm Qsᵀ·A·Qs inplace: {e}"))?;
1368        }
1369        // H_step = H_xtx + (S + step_lm_lambda·I).
1370        let penalty_step = penalty_with_ridge(input.penalty_hessian, input.step_lm_lambda);
1371        let penalty_step_col = to_col_major(&penalty_step);
1372        ws.stream
1373            .memcpy_htod(penalty_step_col.as_ref(), &mut ws.penalty_dev)
1374            .map_err(|e| format!("upload penalty inplace: {e}"))?;
1375        geam_add_inplace(&ws.blas, &ws.stream, p, &mut ws.h_dev, &ws.penalty_dev)?;
1376
1377        // Step 3: rhs = Qsᵀ score_p − S·β + linear_shift  (#257, #260).
1378        // First project score_p through Qsᵀ on device (p×p gemv):
1379        //   beta_orig_dev = Qsᵀ · rhs_dev,  then swap back.
1380        {
1381            let cfg_qts = GemvConfig::<f64> {
1382                trans: cublasOperation_t::CUBLAS_OP_T,
1383                m: p_i,
1384                n: p_i,
1385                alpha: 1.0,
1386                lda: p_i,
1387                incx: 1,
1388                beta: 0.0,
1389                incy: 1,
1390            };
1391            // SAFETY: qs_dev p*p (transposed); rhs_dev length p; beta_orig_dev length p.
1392            unsafe {
1393                ws.blas
1394                    .gemv(cfg_qts, &ws.qs_dev, &ws.rhs_dev, &mut ws.beta_orig_dev)
1395            }
1396            .map_err(|e| format!("dgemv Qsᵀ·score inplace: {e}"))?;
1397            ws.stream
1398                .memcpy_dtod(&ws.beta_orig_dev, &mut ws.rhs_dev)
1399                .map_err(|e| format!("d2d Qsᵀ·score→rhs inplace: {e}"))?;
1400        }
1401        // Now download rhs and β (both p-vectors; small, bounded-cost round-trip).
1402        // Apply rhs −= S·β and rhs += linear_shift on the host for correctness.
1403        let rhs_raw = ws
1404            .stream
1405            .clone_dtoh(&ws.rhs_dev)
1406            .map_err(|e| format!("download Qsᵀ·score inplace: {e}"))?;
1407        let beta_raw = ws
1408            .stream
1409            .clone_dtoh(input.beta_dev)
1410            .map_err(|e| format!("download beta inplace: {e}"))?;
1411        let mut rhs_host = Array1::from_vec(rhs_raw);
1412        let beta_host = Array1::from_vec(beta_raw);
1413        // S·β in transformed coordinates (S = input.penalty_hessian in transformed frame).
1414        let s_beta = input.penalty_hessian.dot(&beta_host);
1415        rhs_host -= &s_beta;
1416        rhs_host += &input.linear_shift;
1417        ws.stream
1418            .memcpy_htod(
1419                rhs_host.as_slice().ok_or("rhs_host not contiguous")?,
1420                &mut ws.rhs_dev,
1421            )
1422            .map_err(|e| format!("re-upload corrected rhs inplace: {e}"))?;
1423
1424        // Step 4: Cholesky factor + solve in-place.
1425        potrf_in_place_reuse(
1426            &ws.solver,
1427            &ws.stream,
1428            p,
1429            ws.potrf_lwork,
1430            &mut ws.h_dev,
1431            &mut ws.potrf_work_dev,
1432            &mut ws.potrf_info_dev,
1433        )?;
1434        potrs_in_place_reuse(
1435            &ws.solver,
1436            &ws.stream,
1437            p,
1438            1,
1439            &ws.h_dev,
1440            &mut ws.rhs_dev,
1441            &mut ws.potrs_info_dev,
1442        )?;
1443        let logdet = cholesky_logdet_device(&ws.stream, &shared.ctx, p, &ws.h_dev)?;
1444        check_deferred_potrf_info(&ws.stream, &ws.potrf_info_dev)?;
1445        check_deferred_potrs_info(&ws.stream, &ws.potrs_info_dev)?;
1446
1447        // ws.rhs_dev = δ = H⁻¹·(Qsᵀ score_p − Sβ + linear_shift) — descent direction.
1448        // No negation: the corrected RHS directly gives the descent direction (#257).
1449        Ok(logdet)
1450    }
1451
1452    /// Rebuild the penalised Hessian `H = XᵀW_hessianX + S + objective_ridge·I`
1453    /// on device using the accepted `w_hessian` weights and download it once.
1454    /// Called once after PIRLS convergence so the exported Hessian reflects
1455    /// the accepted eta, not a stale mid-loop snapshot.
1456    ///
1457    /// Uses `ws.wx_dev`, `ws.xtwx_dev`, `ws.h_dev`, `ws.penalty_dev` as
1458    /// scratch — all are fair game post-loop.
1459    pub(super) fn rebuild_h_final(
1460        shared: &PirlsGpuSharedData,
1461        ws: &mut SigmaPirlsGpuWorkspace,
1462        w_hessian_dev: &CudaSlice<f64>,
1463        penalty_hessian: ArrayView2<'_, f64>,
1464        objective_ridge: f64,
1465    ) -> Result<Array2<f64>, String> {
1466        let n = shared.n;
1467        let p = shared.p;
1468
1469        // XtWX via fused path (no n*p WX temp) or fallback ddgmm + dgemm.
1470        if let Some(ref mut wx_dev_rh) = ws.wx_dev {
1471            // Large-p fallback: WX = diag(w_hessian) · X.
1472            left_scale_rows_borrowed(
1473                &ws.blas,
1474                &ws.stream,
1475                n,
1476                p,
1477                &shared.x_original_dev,
1478                w_hessian_dev,
1479                wx_dev_rh,
1480            )?;
1481            let n_i = to_i32(n)?;
1482            let p_i = to_i32(p)?;
1483            let gemm_cfg = GemmConfig::<f64> {
1484                transa: cublasOperation_t::CUBLAS_OP_T,
1485                transb: cublasOperation_t::CUBLAS_OP_N,
1486                m: p_i,
1487                n: p_i,
1488                k: n_i,
1489                alpha: 1.0,
1490                lda: n_i,
1491                ldb: n_i,
1492                beta: 0.0,
1493                ldc: p_i,
1494            };
1495            // SAFETY: validated dims; shared.x_original_dev and wx_dev_rh n*p
1496            // col-major; ws.xtwx_dev is p*p; all on ws.stream.
1497            unsafe {
1498                ws.blas.gemm(
1499                    gemm_cfg,
1500                    &shared.x_original_dev,
1501                    wx_dev_rh,
1502                    &mut ws.xtwx_dev,
1503                )
1504            }
1505            .map_err(|e| format!("cublas dgemm XtWX (final H rebuild): {e}"))?;
1506        } else {
1507            // Fused path: xtwx_lower + symmetrize, no n*p temp.
1508            launch_xtwx_lower(
1509                &ws.stream,
1510                &shared.ctx,
1511                n,
1512                p,
1513                &shared.x_original_dev,
1514                w_hessian_dev,
1515                &mut ws.xtwx_dev,
1516            )?;
1517            launch_symmetrize_lower(&ws.stream, &shared.ctx, p, &mut ws.xtwx_dev)?;
1518        }
1519
1520        // H_final = Qsᵀ (XtWX) Qs + S + objective_ridge·I.
1521        let p_i = to_i32(p)?;
1522        // tmp = XtWX · Qs → ws.qs_tmp_dev.
1523        {
1524            let cfg_aq = GemmConfig::<f64> {
1525                transa: cublasOperation_t::CUBLAS_OP_N,
1526                transb: cublasOperation_t::CUBLAS_OP_N,
1527                m: p_i,
1528                n: p_i,
1529                k: p_i,
1530                alpha: 1.0,
1531                lda: p_i,
1532                ldb: p_i,
1533                beta: 0.0,
1534                ldc: p_i,
1535            };
1536            // SAFETY: xtwx_dev and qs_dev p*p col-major; qs_tmp_dev p*p output.
1537            unsafe {
1538                ws.blas
1539                    .gemm(cfg_aq, &ws.xtwx_dev, &ws.qs_dev, &mut ws.qs_tmp_dev)
1540            }
1541            .map_err(|e| format!("dgemm A·Qs (final H rebuild): {e}"))?;
1542        }
1543        // H_xtx = Qsᵀ · tmp → ws.h_dev.
1544        {
1545            let cfg_qt = GemmConfig::<f64> {
1546                transa: cublasOperation_t::CUBLAS_OP_T,
1547                transb: cublasOperation_t::CUBLAS_OP_N,
1548                m: p_i,
1549                n: p_i,
1550                k: p_i,
1551                alpha: 1.0,
1552                lda: p_i,
1553                ldb: p_i,
1554                beta: 0.0,
1555                ldc: p_i,
1556            };
1557            // SAFETY: qs_dev p*p (transposed); qs_tmp_dev p*p; h_dev p*p output.
1558            unsafe {
1559                ws.blas
1560                    .gemm(cfg_qt, &ws.qs_dev, &ws.qs_tmp_dev, &mut ws.h_dev)
1561            }
1562            .map_err(|e| format!("dgemm Qsᵀ·A·Qs (final H rebuild): {e}"))?;
1563        }
1564        let penalty = penalty_with_ridge(penalty_hessian, objective_ridge);
1565        let penalty_col = to_col_major(&penalty);
1566        ws.stream
1567            .memcpy_htod(penalty_col.as_ref(), &mut ws.penalty_dev)
1568            .map_err(|e| format!("upload penalty (final H rebuild): {e}"))?;
1569        geam_add_inplace(&ws.blas, &ws.stream, p, &mut ws.h_dev, &ws.penalty_dev)?;
1570
1571        // One download — the only H transfer in the entire PIRLS loop.
1572        let h_col = ws
1573            .stream
1574            .clone_dtoh(&ws.h_dev)
1575            .map_err(|e| format!("download H_final: {e}"))?;
1576        from_col_major(&h_col, p, p).ok_or_else(|| "H_final layout conversion failed".to_string())
1577    }
1578
1579    pub(super) fn weighted_crossprod(
1580        x: ArrayView2<'_, f64>,
1581        weights: ArrayView1<'_, f64>,
1582    ) -> Result<Array2<f64>, String> {
1583        let (_, stream) = context_and_stream()?;
1584        let (n, p) = validate_design(x, weights)?;
1585        let blas = CudaBlas::new(stream.clone()).map_err(|e| format!("cublas init: {e}"))?;
1586        let x_col = to_col_major(&x);
1587        let x_dev = pinned_htod(&stream, &x_col)?;
1588        let mut w_dev = pinned_htod(
1589            &stream,
1590            weights.as_slice().ok_or("weights must be contiguous")?,
1591        )?;
1592        let mut wx_dev = stream
1593            .alloc_zeros::<f64>(n.checked_mul(p).ok_or("X size overflow")?)
1594            .map_err(|e| format!("cuda alloc WX: {e}"))?;
1595        left_scale_rows(&blas, &stream, n, p, &x_dev, &mut w_dev, &mut wx_dev)?;
1596        let mut h_dev = stream
1597            .alloc_zeros::<f64>(p.checked_mul(p).ok_or("H size overflow")?)
1598            .map_err(|e| format!("cuda alloc H: {e}"))?;
1599        let n_i = to_i32(n)?;
1600        let p_i = to_i32(p)?;
1601        let cfg = GemmConfig::<f64> {
1602            transa: cublasOperation_t::CUBLAS_OP_T,
1603            transb: cublasOperation_t::CUBLAS_OP_N,
1604            m: p_i,
1605            n: p_i,
1606            k: n_i,
1607            alpha: 1.0,
1608            lda: n_i,
1609            ldb: n_i,
1610            beta: 0.0,
1611            ldc: p_i,
1612        };
1613        // SAFETY: cuBLAS dgemm with validated i32 dimensions; x_dev/wx_dev are n*p f64 device
1614        // buffers and h_dev is the p*p output, all allocated above with matching sizes.
1615        unsafe { blas.gemm(cfg, &x_dev, &wx_dev, &mut h_dev) }
1616            .map_err(|e| format!("cublas dgemm XtWX: {e}"))?;
1617        let h_col = stream
1618            .clone_dtoh(&h_dev)
1619            .map_err(|e| format!("download H: {e}"))?;
1620        from_col_major(&h_col, p, p).ok_or_else(|| "H layout conversion failed".to_string())
1621    }
1622
1623    pub(super) fn solve_step(input: PirlsGpuInput<'_>) -> Result<PirlsGpuStep, String> {
1624        // One-shot path for the legacy single-step API: validate, build a
1625        // one-shot shared+workspace, run a single step, drop. This routes
1626        // through `solve_step_on_stream` so there is exactly one math path
1627        // for both the batch-mode cubature executor and the single-step
1628        // test/bench surface.
1629        let (_, p) = validate_design(input.x, input.weights)?;
1630        if input.penalty_hessian.dim() != (p, p) {
1631            return Err(format!(
1632                "penalty Hessian shape {:?} does not match p={p}",
1633                input.penalty_hessian.dim()
1634            ));
1635        }
1636        if input.gradient.len() != p {
1637            return Err(format!(
1638                "gradient length {} does not match p={p}",
1639                input.gradient.len()
1640            ));
1641        }
1642        // The legacy single-step API has no GLM data — `solve_step_on_stream`
1643        // (which this dispatches to) only reads `shared.x_original_dev`.
1644        // The shared upload requires y/prior_w/offset for the loop paths, so
1645        // pass zero placeholders sized to the design's row count; they are
1646        // never read by the one-shot Newton step path.
1647        let n_rows = input.x.nrows();
1648        let zero_n = ndarray::Array1::<f64>::zeros(n_rows);
1649        let shared =
1650            PirlsGpuSharedData::upload_impl(input.x, zero_n.view(), zero_n.view(), zero_n.view())?;
1651        let mut ws = SigmaPirlsGpuWorkspace::allocate_impl(&shared)?;
1652        solve_step_on_stream(
1653            &shared,
1654            &mut ws,
1655            PirlsStepStreamInput {
1656                weights: input.weights,
1657                penalty_hessian: input.penalty_hessian,
1658                gradient: input.gradient,
1659                step_lm_lambda: input.step_lm_lambda,
1660                objective_ridge: input.objective_ridge,
1661            },
1662        )
1663    }
1664
1665    fn validate_design(
1666        x: ArrayView2<'_, f64>,
1667        weights: ArrayView1<'_, f64>,
1668    ) -> Result<(usize, usize), String> {
1669        let (n, p) = x.dim();
1670        if weights.len() != n {
1671            return Err(format!(
1672                "weights length {} does not match rows {n}",
1673                weights.len()
1674            ));
1675        }
1676        if n == 0 || p == 0 {
1677            return Err("empty design cannot be solved on CUDA".to_string());
1678        }
1679        Ok((n, p))
1680    }
1681
1682    fn left_scale_rows(
1683        blas: &CudaBlas,
1684        stream: &std::sync::Arc<cudarc::driver::CudaStream>,
1685        n: usize,
1686        p: usize,
1687        x_dev: &CudaSlice<f64>,
1688        w_dev: &mut CudaSlice<f64>,
1689        wx_dev: &mut CudaSlice<f64>,
1690    ) -> Result<(), String> {
1691        let n_i = to_i32(n)?;
1692        let p_i = to_i32(p)?;
1693        let handle = *blas.handle();
1694        let (x_ptr, _x_record) = x_dev.device_ptr(stream);
1695        let (w_ptr, _w_record) = w_dev.device_ptr(stream);
1696        let (wx_ptr, _wx_record) = wx_dev.device_ptr_mut(stream);
1697        // SAFETY: FFI call into cuBLAS; pointers come from live CudaSlice device buffers sized
1698        // n*p (x, wx) and n (w), leading dims match column-major layout, handle is valid.
1699        let status = unsafe {
1700            cublasDdgmm(
1701                handle,
1702                cublasSideMode_t::CUBLAS_SIDE_LEFT,
1703                n_i,
1704                p_i,
1705                x_ptr as *const f64,
1706                n_i,
1707                w_ptr as *const f64,
1708                1,
1709                wx_ptr as *mut f64,
1710                n_i,
1711            )
1712        };
1713        if status == cublasStatus_t::CUBLAS_STATUS_SUCCESS {
1714            Ok(())
1715        } else {
1716            Err(format!("cublasDdgmm failed with {status:?}"))
1717        }
1718    }
1719
1720    /// Borrowed-input variant of [`left_scale_rows`] used by the Stage 3.2
1721    /// device-input PIRLS step. Reads weights through `&CudaSlice` so the
1722    /// caller can keep ownership of the row-reweight buffer across the
1723    /// PIRLS iteration without an extra device-side copy.
1724    fn left_scale_rows_borrowed(
1725        blas: &CudaBlas,
1726        stream: &std::sync::Arc<cudarc::driver::CudaStream>,
1727        n: usize,
1728        p: usize,
1729        x_dev: &CudaSlice<f64>,
1730        w_dev: &CudaSlice<f64>,
1731        wx_dev: &mut CudaSlice<f64>,
1732    ) -> Result<(), String> {
1733        let n_i = to_i32(n)?;
1734        let p_i = to_i32(p)?;
1735        let handle = *blas.handle();
1736        let (x_ptr, _x_record) = x_dev.device_ptr(stream);
1737        let (w_ptr, _w_record) = w_dev.device_ptr(stream);
1738        let (wx_ptr, _wx_record) = wx_dev.device_ptr_mut(stream);
1739        // SAFETY: FFI call into cuBLAS; pointers come from live CudaSlice
1740        // device buffers; x is n*p col-major (lda = n), w is length n
1741        // (stride 1), wx is n*p output (lda = n). Caller-owned w buffer
1742        // is borrowed read-only here, matching cublasDdgmm's contract.
1743        let status = unsafe {
1744            cublasDdgmm(
1745                handle,
1746                cublasSideMode_t::CUBLAS_SIDE_LEFT,
1747                n_i,
1748                p_i,
1749                x_ptr as *const f64,
1750                n_i,
1751                w_ptr as *const f64,
1752                1,
1753                wx_ptr as *mut f64,
1754                n_i,
1755            )
1756        };
1757        if status == cublasStatus_t::CUBLAS_STATUS_SUCCESS {
1758            Ok(())
1759        } else {
1760            Err(format!("cublasDdgmm (borrowed) failed with {status:?}"))
1761        }
1762    }
1763
1764    // In-place `a := a + b` for two `p*p` column-major device buffers via
1765    // cublasDgeam. The C API explicitly permits `C = A` (output aliasing the
1766    // first input), but Rust's borrow checker cannot prove that — every
1767    // caller historically passed `&ws.h_dev, &ws.penalty_dev, &mut ws.h_dev`
1768    // and ran into E0502. Forcing the in-place semantics into the wrapper
1769    // signature makes the contract explicit and removes the aliasing-borrow
1770    // class of errors at the call sites.
1771    fn geam_add_inplace(
1772        blas: &CudaBlas,
1773        stream: &std::sync::Arc<cudarc::driver::CudaStream>,
1774        p: usize,
1775        a: &mut CudaSlice<f64>,
1776        b: &CudaSlice<f64>,
1777    ) -> Result<(), String> {
1778        let p_i = to_i32(p)?;
1779        let alpha = 1.0_f64;
1780        let beta = 1.0_f64;
1781        let handle = *blas.handle();
1782        let (b_ptr, _b_record) = b.device_ptr(stream);
1783        let (a_ptr, _a_record) = a.device_ptr_mut(stream);
1784        // cublasDgeam with C == A is allowed and computes `A := alpha*A + beta*B`.
1785        let out_ptr = a_ptr;
1786        // SAFETY: FFI call into cuBLAS geam; a, b, out are live p*p device buffers in column-major
1787        // with leading dim p_i, scalars live on host stack, handle is valid.
1788        let status = unsafe {
1789            cublasDgeam(
1790                handle,
1791                cublasOperation_t::CUBLAS_OP_N,
1792                cublasOperation_t::CUBLAS_OP_N,
1793                p_i,
1794                p_i,
1795                &alpha,
1796                a_ptr as *const f64,
1797                p_i,
1798                &beta,
1799                b_ptr as *const f64,
1800                p_i,
1801                out_ptr as *mut f64,
1802                p_i,
1803            )
1804        };
1805        if status == cublasStatus_t::CUBLAS_STATUS_SUCCESS {
1806            Ok(())
1807        } else {
1808            Err(format!("cublasDgeam failed with {status:?}"))
1809        }
1810    }
1811
1812    /// Launch the `xtwx_lower` kernel: one thread per lower-tri pair `(j,k)`,
1813    /// iterates over all `n` rows and writes `A[j + k*p]` (col-major lower
1814    /// triangle of `XᵀWX`). Call `launch_symmetrize_lower` afterwards.
1815    fn launch_xtwx_lower(
1816        stream: &std::sync::Arc<cudarc::driver::CudaStream>,
1817        ctx: &std::sync::Arc<cudarc::driver::CudaContext>,
1818        n: usize,
1819        p: usize,
1820        x_dev: &CudaSlice<f64>,
1821        w_dev: &CudaSlice<f64>,
1822        a_dev: &mut CudaSlice<f64>,
1823    ) -> Result<(), String> {
1824        let module = FUSED_XTWX_CACHE
1825            .get_or_compile(ctx, "fused_xtwx", FUSED_XTWX_PTX_SOURCE)
1826            .map_err(|e| format!("fused_xtwx module: {e}"))?;
1827        let func = module
1828            .load_function("xtwx_lower")
1829            .map_err(|e| format!("load xtwx_lower: {e}"))?;
1830        let n_i = to_i32(n)?;
1831        let p_i = to_i32(p)?;
1832        let num_pairs = p * (p + 1) / 2;
1833        let num_pairs_u32 = u32::try_from(num_pairs)
1834            .map_err(|_| format!("xtwx_lower: num_pairs {num_pairs} > u32"))?;
1835        const BLOCK: u32 = 256;
1836        let grid = num_pairs_u32.div_ceil(BLOCK).max(1);
1837        let cfg = cudarc::driver::LaunchConfig {
1838            grid_dim: (grid, 1, 1),
1839            block_dim: (BLOCK, 1, 1),
1840            shared_mem_bytes: 0,
1841        };
1842        let mut builder = stream.launch_builder(&func);
1843        builder.arg(x_dev);
1844        builder.arg(w_dev);
1845        builder.arg(a_dev);
1846        builder.arg(&n_i);
1847        builder.arg(&p_i);
1848        // SAFETY: x_dev is n*p col-major f64; w_dev is length n; a_dev is p*p;
1849        // num_pairs threads each write one lower-tri entry A[j + k*p].
1850        unsafe { builder.launch(cfg) }
1851            .map_err(|e| format!("xtwx_lower launch: {e}"))
1852            .map(|_| ())
1853    }
1854
1855    /// Launch the `xtscore` kernel: one thread per output index `j`,
1856    /// iterates over `n` rows and writes `s[j] = sum_i score[i]*X[i,j]`.
1857    fn launch_xtscore(
1858        stream: &std::sync::Arc<cudarc::driver::CudaStream>,
1859        ctx: &std::sync::Arc<cudarc::driver::CudaContext>,
1860        n: usize,
1861        p: usize,
1862        x_dev: &CudaSlice<f64>,
1863        score_dev: &CudaSlice<f64>,
1864        s_dev: &mut CudaSlice<f64>,
1865    ) -> Result<(), String> {
1866        let module = FUSED_XTWX_CACHE
1867            .get_or_compile(ctx, "fused_xtwx", FUSED_XTWX_PTX_SOURCE)
1868            .map_err(|e| format!("fused_xtwx module (xtscore): {e}"))?;
1869        let func = module
1870            .load_function("xtscore")
1871            .map_err(|e| format!("load xtscore: {e}"))?;
1872        let n_i = to_i32(n)?;
1873        let p_i = to_i32(p)?;
1874        let p_u32 = u32::try_from(p).map_err(|_| format!("xtscore: p {p} > u32"))?;
1875        const BLOCK: u32 = 256;
1876        let grid = p_u32.div_ceil(BLOCK).max(1);
1877        let cfg = cudarc::driver::LaunchConfig {
1878            grid_dim: (grid, 1, 1),
1879            block_dim: (BLOCK, 1, 1),
1880            shared_mem_bytes: 0,
1881        };
1882        let mut builder = stream.launch_builder(&func);
1883        builder.arg(x_dev);
1884        builder.arg(score_dev);
1885        builder.arg(s_dev);
1886        builder.arg(&n_i);
1887        builder.arg(&p_i);
1888        // SAFETY: x_dev is n*p col-major f64; score_dev is length n; s_dev is length p;
1889        // p threads each write one output entry s[j].
1890        unsafe { builder.launch(cfg) }
1891            .map_err(|e| format!("xtscore launch: {e}"))
1892            .map(|_| ())
1893    }
1894
1895    /// Launch the `symmetrize_lower` kernel: one thread per strict lower-tri
1896    /// pair `(j,k)` with `j > k`; copies `A[k + j*p] = A[j + k*p]` to fill
1897    /// the upper triangle from the lower triangle populated by `xtwx_lower`.
1898    fn launch_symmetrize_lower(
1899        stream: &std::sync::Arc<cudarc::driver::CudaStream>,
1900        ctx: &std::sync::Arc<cudarc::driver::CudaContext>,
1901        p: usize,
1902        a_dev: &mut CudaSlice<f64>,
1903    ) -> Result<(), String> {
1904        if p <= 1 {
1905            return Ok(());
1906        }
1907        let module = FUSED_XTWX_CACHE
1908            .get_or_compile(ctx, "fused_xtwx", FUSED_XTWX_PTX_SOURCE)
1909            .map_err(|e| format!("fused_xtwx module (sym): {e}"))?;
1910        let func = module
1911            .load_function("symmetrize_lower")
1912            .map_err(|e| format!("load symmetrize_lower: {e}"))?;
1913        let p_i = to_i32(p)?;
1914        let num_strict = p * (p - 1) / 2;
1915        let num_strict_u32 = u32::try_from(num_strict)
1916            .map_err(|_| format!("symmetrize_lower: num_strict {num_strict} > u32"))?;
1917        const BLOCK: u32 = 256;
1918        let grid = num_strict_u32.div_ceil(BLOCK).max(1);
1919        let cfg = cudarc::driver::LaunchConfig {
1920            grid_dim: (grid, 1, 1),
1921            block_dim: (BLOCK, 1, 1),
1922            shared_mem_bytes: 0,
1923        };
1924        let mut builder = stream.launch_builder(&func);
1925        builder.arg(a_dev);
1926        builder.arg(&p_i);
1927        // SAFETY: a_dev is p*p col-major f64; each of the num_strict threads
1928        // writes one upper-triangle entry mirrored from the lower triangle.
1929        unsafe { builder.launch(cfg) }
1930            .map_err(|e| format!("symmetrize_lower launch: {e}"))
1931            .map(|_| ())
1932    }
1933
1934    /// Launch the device-side Cholesky-factor logdet kernel and download
1935    /// the single scalar result. Replaces the per-step p² host download of
1936    /// the Cholesky factor that the host-side `cholesky_logdet_from_col_major`
1937    /// required.
1938    fn cholesky_logdet_device(
1939        stream: &std::sync::Arc<cudarc::driver::CudaStream>,
1940        ctx: &std::sync::Arc<cudarc::driver::CudaContext>,
1941        p: usize,
1942        factor_dev: &CudaSlice<f64>,
1943    ) -> Result<f64, String> {
1944        let module = CHOL_LOGDET_CACHE
1945            .get_or_compile(ctx, "pirls_gpu_chol_logdet", CHOL_LOGDET_PTX_SOURCE)
1946            .map_err(|err| format!("chol_logdet module: {err}"))?;
1947        let func = module
1948            .load_function("chol_logdet_col_major")
1949            .map_err(|err| format!("chol_logdet load_function: {err}"))?;
1950        let mut out_dev = stream
1951            .alloc_zeros::<f64>(1)
1952            .map_err(|err| format!("alloc chol_logdet out: {err}"))?;
1953        let p_i = to_i32(p)?;
1954        let cfg = LaunchConfig {
1955            grid_dim: (1, 1, 1),
1956            block_dim: (1, 1, 1),
1957            shared_mem_bytes: 0,
1958        };
1959        let mut builder = stream.launch_builder(&func);
1960        builder.arg(factor_dev);
1961        builder.arg(&p_i);
1962        builder.arg(&mut out_dev);
1963        // SAFETY: serial single-thread kernel reading `p` f64 diagonal
1964        // entries from a live p*p column-major factor and writing one f64
1965        // to `out_dev`; no aliasing, no oob — `p` matches the device buffer
1966        // shape every caller passes in.
1967        unsafe { builder.launch(cfg) }.map_err(|err| format!("chol_logdet launch: {err}"))?;
1968        let out_host = stream
1969            .clone_dtoh(&out_dev)
1970            .map_err(|err| format!("download chol_logdet: {err}"))?;
1971        Ok(out_host[0])
1972    }
1973
1974    fn penalty_with_ridge(penalty: ArrayView2<'_, f64>, ridge: f64) -> Array2<f64> {
1975        let mut out = penalty.to_owned();
1976        if ridge != 0.0 {
1977            for i in 0..out.nrows().min(out.ncols()) {
1978                out[[i, i]] += ridge;
1979            }
1980        }
1981        out
1982    }
1983
1984    fn to_i32(value: usize) -> Result<i32, String> {
1985        i32::try_from(value).map_err(|_| format!("CUDA dimension {value} exceeds i32"))
1986    }
1987
1988    // ────────────────────────────────────────────────────────────────────
1989    // Stage 3.3: full device-resident PIRLS loop driver
1990    // ────────────────────────────────────────────────────────────────────
1991
1992    /// Bundled NVRTC helpers for the Stage 3.3 loop driver: axpy +
1993    /// single-block sum / linf reductions. Cached process-wide.
1994    const PIRLS_LOOP_PTX_SOURCE: &str = r#"
1995// __device__ annotation required by newer NVRTC JIT semantics (see
1996// gpu_kernels/pirls_row.rs common_device_prolog — the #2313 hardware sweep).
1997extern "C" {
1998    __device__ double fabs(double);
1999}
2000
2001extern "C" __global__ void axpy_n(
2002    double alpha,
2003    const double* __restrict__ x,
2004    double* __restrict__ y,
2005    int n
2006) {
2007    int i = blockIdx.x * blockDim.x + threadIdx.x;
2008    if (i >= n) return;
2009    y[i] += alpha * x[i];
2010}
2011
2012extern "C" __global__ void deviance_sum(
2013    const double* __restrict__ d,
2014    int n,
2015    double* __restrict__ out
2016) {
2017    __shared__ double sm[1024];
2018    int tid = threadIdx.x;
2019    int bdim = blockDim.x;
2020    double acc = 0.0;
2021    for (int i = tid; i < n; i += bdim) {
2022        acc += d[i];
2023    }
2024    sm[tid] = acc;
2025    __syncthreads();
2026    for (int stride = bdim / 2; stride > 0; stride >>= 1) {
2027        if (tid < stride) sm[tid] += sm[tid + stride];
2028        __syncthreads();
2029    }
2030    if (tid == 0) out[0] = sm[0];
2031}
2032
2033extern "C" __global__ void linf_norm(
2034    const double* __restrict__ v,
2035    int p,
2036    double* __restrict__ out
2037) {
2038    __shared__ double sm[1024];
2039    int tid = threadIdx.x;
2040    int bdim = blockDim.x;
2041    double acc = 0.0;
2042    for (int i = tid; i < p; i += bdim) {
2043        double a = fabs(v[i]);
2044        if (a > acc) acc = a;
2045    }
2046    sm[tid] = acc;
2047    __syncthreads();
2048    for (int stride = bdim / 2; stride > 0; stride >>= 1) {
2049        if (tid < stride) {
2050            double r = sm[tid + stride];
2051            if (r > sm[tid]) sm[tid] = r;
2052        }
2053        __syncthreads();
2054    }
2055    if (tid == 0) out[0] = sm[0];
2056}
2057
2058extern "C" __global__ void negate_n(
2059    double* __restrict__ v,
2060    int n
2061) {
2062    int i = blockIdx.x * blockDim.x + threadIdx.x;
2063    if (i >= n) return;
2064    v[i] = -v[i];
2065}
2066
2067// Deterministically select the smallest failing row. out[0] is UINT_MAX on
2068// success, otherwise the row index; out[1] carries that row's refusal code.
2069extern "C" __global__ void status_first(
2070    const unsigned int* __restrict__ status,
2071    int n,
2072    unsigned int* __restrict__ out
2073) {
2074    __shared__ unsigned int sm_row[1024];
2075    __shared__ unsigned int sm_code[1024];
2076    int tid = threadIdx.x;
2077    int bdim = blockDim.x;
2078    unsigned int best_row = 0xffffffffu;
2079    unsigned int best_code = 0u;
2080    for (int i = tid; i < n; i += bdim) {
2081        unsigned int code = status[i];
2082        if (code != 0u && (unsigned int)i < best_row) {
2083            best_row = (unsigned int)i;
2084            best_code = code;
2085        }
2086    }
2087    sm_row[tid] = best_row;
2088    sm_code[tid] = best_code;
2089    __syncthreads();
2090    for (int stride = bdim / 2; stride > 0; stride >>= 1) {
2091        if (tid < stride && sm_row[tid + stride] < sm_row[tid]) {
2092            sm_row[tid] = sm_row[tid + stride];
2093            sm_code[tid] = sm_code[tid + stride];
2094        }
2095        __syncthreads();
2096    }
2097    if (tid == 0) {
2098        out[0] = sm_row[0];
2099        out[1] = sm_code[0];
2100    }
2101}
2102
2103// Same deterministic reduction for the alpha-major [7*n] ladder status
2104// matrix. One block handles each alpha; outputs are row[0..7), code[7..14).
2105extern "C" __global__ void status_first_ladder(
2106    const unsigned int* __restrict__ status,
2107    int n,
2108    unsigned int* __restrict__ out
2109) {
2110    __shared__ unsigned int sm_row[1024];
2111    __shared__ unsigned int sm_code[1024];
2112    int k = blockIdx.x;
2113    int tid = threadIdx.x;
2114    int bdim = blockDim.x;
2115    unsigned int best_row = 0xffffffffu;
2116    unsigned int best_code = 0u;
2117    const unsigned int* candidate = status + ((long long)k * n);
2118    for (int i = tid; i < n; i += bdim) {
2119        unsigned int code = candidate[i];
2120        if (code != 0u && (unsigned int)i < best_row) {
2121            best_row = (unsigned int)i;
2122            best_code = code;
2123        }
2124    }
2125    sm_row[tid] = best_row;
2126    sm_code[tid] = best_code;
2127    __syncthreads();
2128    for (int stride = bdim / 2; stride > 0; stride >>= 1) {
2129        if (tid < stride && sm_row[tid + stride] < sm_row[tid]) {
2130            sm_row[tid] = sm_row[tid + stride];
2131            sm_code[tid] = sm_code[tid + stride];
2132        }
2133        __syncthreads();
2134    }
2135    if (tid == 0) {
2136        out[k] = sm_row[0];
2137        out[7 + k] = sm_code[0];
2138    }
2139}
2140"#;
2141
2142    static PIRLS_LOOP_CACHE: PtxModuleCache = PtxModuleCache::new();
2143
2144    /// Per-fit device workspace for the Stage 3.3 PIRLS loop driver.
2145    ///
2146    /// Three row-kernel modes occupy separate device buffers:
2147    /// - `row_solve`: solve-row (4 fields), refreshed each Newton iteration.
2148    /// - `alpha_ladder`: candidate-objective (objective[7] + status[7*n]).
2149    /// - `row_final`: five numerical fields + status, written once at convergence.
2150    pub struct PirlsLoopWorkspace {
2151        pub beta_dev: CudaSlice<f64>,
2152        pub eta_dev: CudaSlice<f64>,
2153        /// Solve-row buffers: `grad_eta`, `w_solver`, `deviance`, `status`.
2154        pub row_solve: crate::gpu_kernels::pirls_row::SolveRowBuffers,
2155        /// Alpha-ladder buffers: `objective[7]`, alpha-major `status[7*n]`.
2156        pub alpha_ladder: crate::gpu_kernels::pirls_row::AlphaLadderDevBuffers,
2157        /// Full production final-row buffers, written once at convergence.
2158        pub row_final: crate::gpu_kernels::pirls_row::RowOutputDevBuffers,
2159        pub direction_dev: CudaSlice<f64>,
2160        pub xd_dev: CudaSlice<f64>,
2161        pub scalar_dev: CudaSlice<f64>,
2162        /// Fourteen u32 scratch slots: row/code pairs for one row surface or
2163        /// all seven alpha-ladder candidates.
2164        pub status_u32_dev: CudaSlice<u32>,
2165        pub n: usize,
2166        pub p: usize,
2167    }
2168
2169    impl PirlsLoopWorkspace {
2170        pub fn allocate(
2171            shared: &PirlsGpuSharedData,
2172            stream: &std::sync::Arc<cudarc::driver::CudaStream>,
2173        ) -> Result<Self, String> {
2174            let n = shared.n;
2175            let p = shared.p;
2176            let alloc_f64 = |label: &'static str, len: usize| {
2177                stream
2178                    .alloc_zeros::<f64>(len)
2179                    .map_err(|e| format!("pirls loop alloc {label}: {e}"))
2180            };
2181            Ok(Self {
2182                beta_dev: alloc_f64("beta", p)?,
2183                eta_dev: alloc_f64("eta", n)?,
2184                row_solve: crate::gpu_kernels::pirls_row::SolveRowBuffers::allocate(stream, n)
2185                    .map_err(|e| format!("pirls loop alloc row_solve: {e}"))?,
2186                alpha_ladder: crate::gpu_kernels::pirls_row::AlphaLadderDevBuffers::allocate(
2187                    stream, n,
2188                )
2189                .map_err(|e| format!("pirls loop alloc alpha_ladder: {e}"))?,
2190                row_final: crate::gpu_kernels::pirls_row::RowOutputDevBuffers::allocate(stream, n)
2191                    .map_err(|e| format!("pirls loop alloc row_final: {e}"))?,
2192                direction_dev: alloc_f64("direction", p)?,
2193                xd_dev: alloc_f64("xd", n)?,
2194                scalar_dev: alloc_f64("scalar", 1)?,
2195                status_u32_dev: stream
2196                    .alloc_zeros::<u32>(14)
2197                    .map_err(|e| format!("pirls loop alloc status_u32: {e}"))?,
2198                n,
2199                p,
2200            })
2201        }
2202    }
2203
2204    /// Optional host-side inputs that turn the bare GPU loop result
2205    /// into a full-surface `PirlsLoopOutcome` matching the CPU oracle
2206    /// `fit_model_for_fixed_rho_with_adaptive_kkt`.
2207    ///
2208    /// When supplied, the postpass at loop exit runs the same host-side
2209    /// helpers the CPU oracle uses
2210    /// (`computeworkingweight_derivatives_from_eta`,
2211    /// `compute_observed_hessian_curvature_arrays`,
2212    /// `compute_constraint_kkt_diagnostics`) so the dispatch wirer can
2213    /// plumb every field of `PirlsResult` without doing math.
2214    ///
2215    /// When `None`, the derived fields on `PirlsLoopOutcome`
2216    /// (`finalweights`, `solveweights`, `solve_dmu_deta`,
2217    /// `solve_d2mu_deta2`, `solve_d3mu_deta3`, `solve_c_array`,
2218    /// `solve_d_array`, `status`, `constraint_kkt`, `ridge_passport`,
2219    /// `firth`, `edf`, `beta_transformed`, `derivatives_unsupported`)
2220    /// take safe defaults: empty arrays, `PirlsStatus::Converged` or
2221    /// `MaxIterationsReached` reflecting `converged`, no KKT
2222    /// diagnostics, identity ridge with `objective_ridge` magnitude,
2223    /// `FirthDiagnostics::Inactive`, `edf = NaN`,
2224    /// `beta_transformed = beta`, `derivatives_unsupported = true`.
2225    /// Existing callers that do not need the CPU oracle surface can
2226    /// pass `None` and ignore the derived fields.
2227    pub struct PirlsLoopExtra<'a> {
2228        /// GLM likelihood spec the row kernel was driven by. Needed by
2229        /// `computeworkingweight_derivatives_from_eta` to produce
2230        /// `solve_dmu_deta` / `solve_d2mu_deta2` / `solve_d3mu_deta3`
2231        /// and the score-side `c` / `d` arrays.
2232        pub likelihood: &'a gam_problem::GlmLikelihoodSpec,
2233        /// Inverse link the row kernel was driven by; pairs with
2234        /// `likelihood` for the family-specific derivatives.
2235        pub inverse_link: &'a gam_problem::InverseLink,
2236        /// Response vector `y` (length `n`) — same view passed to the
2237        /// row kernel. Needed for observed-curvature finalization.
2238        pub y: ndarray::ArrayView1<'a, f64>,
2239        /// Prior weights (length `n`) — same view passed to the row
2240        /// kernel. Carried through to the curvature helpers.
2241        pub priorweights: ndarray::ArrayView1<'a, f64>,
2242        /// Observation offset (length `n`). Stored verbatim on the
2243        /// outcome's `final_offset` so the dispatch wirer can populate
2244        /// `PirlsResult::final_offset` without re-allocating.
2245        pub offset: ndarray::ArrayView1<'a, f64>,
2246        /// Linear inequality constraints `A·β ≥ b` in the same
2247        /// coordinate frame as the GPU loop's β. When `Some`, the
2248        /// postpass calls `compute_constraint_kkt_diagnostics` on the
2249        /// converged β + reconstructed penalised gradient and emits
2250        /// the result on `PirlsLoopOutcome::constraint_kkt`. When
2251        /// `None`, no diagnostics are produced.
2252        pub linear_constraints: Option<&'a gam_problem::LinearInequalityConstraints>,
2253        /// Curvature surface the *outer* REML / LAML caller expects on
2254        /// the returned Hessian. The GPU loop runs under whatever
2255        /// `curvature: CurvatureMode` it was invoked with; if this
2256        /// differs (e.g. inner loop ran Fisher for stability but the
2257        /// outer caller demands observed curvature), the postpass
2258        /// promotes `finalweights` / `solve_c_array` / `solve_d_array`
2259        /// via `compute_observed_hessian_curvature_arrays` so the
2260        /// outcome matches the CPU oracle's `exported_laplace_curvature`
2261        /// contract.
2262        pub exported_curvature: crate::pirls::HessianCurvatureKind,
2263        /// Pre-built ridge passport carrying the stabilization
2264        /// magnitude + policy that the dispatch wirer wants stamped on
2265        /// `PirlsResult::ridge_passport`. When `None`, the postpass
2266        /// uses `RidgePassport::scaled_identity(objective_ridge,
2267        /// RidgePolicy::explicit_stabilization_full())`, which mirrors
2268        /// the CPU oracle's default for a no-escalation fit.
2269        pub ridge_passport: Option<gam_problem::RidgePassport>,
2270        /// Firth bias-reduction diagnostics. Today the GPU loop does
2271        /// not implement Firth; pass `None` to land
2272        /// `FirthDiagnostics::Inactive` on the outcome. A future
2273        /// device-side Firth path would populate this with the active
2274        /// Jeffreys-logdet + hat-diagonal vector.
2275        pub firth: Option<crate::pirls::FirthDiagnostics>,
2276        /// Effective degrees of freedom at the converged mode, when
2277        /// the dispatch wirer has it precomputed (typical case: the
2278        /// outer REML caller passes its own `e_transformed` /
2279        /// diagonal-penalty pre-image and computes EDF host-side).
2280        /// When `None`, the postpass emits `f64::NAN` and sets
2281        /// `derivatives_unsupported = true` — the dispatch wirer can
2282        /// then compute EDF itself from `penalized_hessian` and the
2283        /// caller-side penalty root.
2284        pub edf: Option<f64>,
2285    }
2286
2287    #[derive(Clone, Debug)]
2288    pub struct PirlsLoopOutcome {
2289        pub beta: Array1<f64>,
2290        pub penalized_hessian: Array2<f64>,
2291        pub logdet: f64,
2292        pub deviance: f64,
2293        pub iterations: usize,
2294        pub converged: bool,
2295        /// Final linear predictor η = X·β at the accepted PIRLS step
2296        /// (length `n`). Downloaded once at loop exit.
2297        pub final_eta: Array1<f64>,
2298        /// Mean response μ = g⁻¹(η) at the accepted step, length `n`.
2299        /// Maps to `PirlsResult::finalmu` / `solvemu`.
2300        pub final_mu: Array1<f64>,
2301        /// Score-side gradient contribution `∂ℓ/∂η_i` at the accepted
2302        /// step (length `n`). The CPU oracle uses this to form
2303        /// `score_norm = ‖Xᵀ grad_eta‖₂`.
2304        pub final_grad_eta: Array1<f64>,
2305        /// Hessian-side diagonal working weight `w_hessian_i` at the
2306        /// accepted step. Maps to `PirlsResult::finalweights` when no
2307        /// observed-curvature promotion is requested.
2308        pub final_w_hessian: Array1<f64>,
2309        /// Score-side diagonal working weight `w_solver_i` at the
2310        /// accepted step. Maps to `PirlsResult::solveweights`.
2311        pub final_w_solver: Array1<f64>,
2312        /// Observation offset (length `n`). Echoed from
2313        /// `PirlsLoopExtra::offset` when supplied, otherwise an empty
2314        /// array. Maps to `PirlsResult::final_offset`.
2315        pub final_offset: Array1<f64>,
2316        /// β in the canonical transformed basis. Always equals
2317        /// `beta` because the GPU loop solved in the transformed
2318        /// design `X·Qs`, so the loop's β is already transformed.
2319        /// Maps to `PirlsResult::beta_transformed`.
2320        pub beta_transformed: Array1<f64>,
2321        /// Hessian-side `finalweights` after optional Fisher→observed
2322        /// promotion driven by `extra.exported_curvature`. Empty when
2323        /// `extra` is `None`.
2324        pub finalweights: Array1<f64>,
2325        /// Score-side `solveweights` (= `final_w_solver`) echoed
2326        /// through so the dispatch wirer can stamp directly.
2327        pub solveweights: Array1<f64>,
2328        /// Solve-side `dμ/dη` at the converged η, family-specific.
2329        /// From `computeworkingweight_derivatives_from_eta`. Empty
2330        /// when `extra` is `None`.
2331        pub solve_dmu_deta: Array1<f64>,
2332        /// Solve-side `d²μ/dη²`. Empty when `extra` is `None`.
2333        pub solve_d2mu_deta2: Array1<f64>,
2334        /// Solve-side `d³μ/dη³`. Empty when `extra` is `None`.
2335        pub solve_d3mu_deta3: Array1<f64>,
2336        /// `c_i = dW_i/dη_i` at the converged mode (Fisher or
2337        /// observed depending on `extra.exported_curvature`). Maps to
2338        /// `PirlsResult::solve_c_array`. Empty when `extra` is `None`.
2339        pub solve_c_array: Array1<f64>,
2340        /// `d_i = d²W_i/dη_i²`. Maps to `PirlsResult::solve_d_array`.
2341        /// Empty when `extra` is `None`.
2342        pub solve_d_array: Array1<f64>,
2343        /// `true` when the family's analytic 3rd/4th derivatives are
2344        /// not supported and the c/d arrays are placeholders. Mirrors
2345        /// `PirlsResult::derivatives_unsupported`.
2346        pub derivatives_unsupported: bool,
2347        /// PirlsStatus the dispatch wirer should propagate. Emitted as
2348        /// `Converged` when the loop's tolerance test passed and
2349        /// `final_eta`/`final_mu` are finite; `Unstable` when any of
2350        /// those go non-finite; `MaxIterationsReached` when the loop
2351        /// hit its iteration cap without converging.
2352        pub status: crate::pirls::PirlsStatus,
2353        /// Ridge passport carrying the stabilization δ and policy.
2354        /// When `extra.ridge_passport` is `Some`, this is the supplied
2355        /// value verbatim. Otherwise a default `scaled_identity(
2356        /// objective_ridge, explicit_stabilization_full())` passport.
2357        pub ridge_passport: gam_problem::RidgePassport,
2358        /// Firth diagnostics. `Inactive` unless the caller passes an
2359        /// `Active` value through `extra.firth`.
2360        pub firth: crate::pirls::FirthDiagnostics,
2361        /// KKT diagnostics for `extra.linear_constraints`. `None`
2362        /// either when no constraints are supplied or when the
2363        /// constraint system is empty.
2364        pub constraint_kkt: Option<crate::active_set::ConstraintKktDiagnostics>,
2365        /// Effective degrees of freedom. Echoed from `extra.edf`;
2366        /// `f64::NAN` when not supplied.
2367        pub edf: f64,
2368        /// `prev_deviance − accepted_deviance` at the accepted step
2369        /// that terminated the loop. Matches the CPU oracle's
2370        /// `WorkingModelPirlsResult::last_deviance_change`.
2371        pub last_deviance_change: f64,
2372        /// Number of line-search halvings consumed on the accepted
2373        /// step (`k` when α = `0.5^k`; `0` when α = 1). When the
2374        /// ladder was fully exhausted (`step_search_exhausted`), this
2375        /// is `0` and `last_step_size = 0.0` — no step was committed.
2376        /// Mirrors `WorkingModelPirlsResult::last_step_halving`.
2377        pub last_step_halving: usize,
2378        /// Step size α that was accepted at the final iteration.
2379        /// Mirrors `WorkingModelPirlsResult::last_step_size`.
2380        pub last_step_size: f64,
2381        /// Levenberg-Marquardt damping coefficient (step_lm_lambda) in
2382        /// effect at the last accepted iter. The GPU loop has no
2383        /// on-device ridge escalation (it is a constant per call), so
2384        /// this echoes the input `step_lm_lambda`. Maps to
2385        /// `PirlsResult::final_lm_lambda`.
2386        pub final_lm_lambda: f64,
2387        /// Running minimum of the data-side deviance observed across
2388        /// all accepted Newton steps. The GPU loop only knows the
2389        /// data deviance device-side; the dispatch wirer can add
2390        /// `βᵀ·penalty_hessian·β` at the converged β to obtain the
2391        /// fully penalised running minimum when needed for
2392        /// `PirlsResult::min_penalized_deviance`.
2393        pub min_deviance: f64,
2394        /// `max_i |η_i|` at the accepted final step — the saturation
2395        /// diagnostic the CPU oracle stamps on
2396        /// `PirlsResult::max_abs_eta`. Used by REML's
2397        /// perfect-separation detection.
2398        pub max_abs_eta: f64,
2399    }
2400
2401    /// Full device-resident PIRLS loop. Only three scalar (1 f64)
2402    /// downloads per Newton iter (deviance, direction-L∞, candidate
2403    /// deviance per α). β + final H downloaded once at exit.
2404    pub(super) fn pirls_loop(
2405        shared: &PirlsGpuSharedData,
2406        ws: &mut SigmaPirlsGpuWorkspace,
2407        loop_ws: &mut PirlsLoopWorkspace,
2408        family: crate::gpu_kernels::pirls_row::PirlsRowFamily,
2409        curvature: crate::gpu_kernels::pirls_row::CurvatureMode,
2410        // Active Gamma dispersion shape (α > 0). Forwarded to every
2411        // `launch_row_reweight_on_stream` call. Pass `1.0` for non-Gamma fits.
2412        gamma_shape: f64,
2413        beta0_host: ArrayView1<'_, f64>,
2414        penalty_hessian: ArrayView2<'_, f64>,
2415        // Linear shift `b` of the shifted-quadratic penalty
2416        // `βᵀSβ − 2βᵀb + c`. Length `p`. Mirrors
2417        // `PirlsPenalty::linear_shift()` in the CPU oracle. Pass a zero
2418        // vector for fits with no prior-mean shift.
2419        linear_shift: ArrayView1<'_, f64>,
2420        // Constant shift `c` of the shifted-quadratic penalty. Pass
2421        // `0.0` for fits with no prior-mean shift.
2422        constant_shift: f64,
2423        // Temporary LM damping for the Newton solves only; never enters
2424        // RidgePassport / exported Hessian / EDF / penalty term.
2425        lm_ridge: f64,
2426        // Real model-objective ridge; enters RidgePassport / exported
2427        // Hessian / EDF / penalty term.
2428        objective_ridge: f64,
2429        max_iter: usize,
2430        tol: f64,
2431        extra: Option<&PirlsLoopExtra<'_>>,
2432    ) -> Result<PirlsLoopOutcome, PirlsGpuLoopError> {
2433        let n = shared.n;
2434        let p = shared.p;
2435        if loop_ws.n != n || loop_ws.p != p {
2436            return Err(format!(
2437                "loop workspace ({}, {}) ≠ shared ({n}, {p})",
2438                loop_ws.n, loop_ws.p
2439            )
2440            .into());
2441        }
2442        if beta0_host.len() != p {
2443            return Err(format!("beta0 length {} ≠ p={p}", beta0_host.len()).into());
2444        }
2445
2446        if linear_shift.len() != p {
2447            return Err(format!("linear_shift length {} ≠ p={p}", linear_shift.len()).into());
2448        }
2449        if penalty_hessian.dim() != (p, p) {
2450            return Err(format!(
2451                "penalty_hessian shape {:?} ≠ (p={p}, p={p})",
2452                penalty_hessian.dim()
2453            )
2454            .into());
2455        }
2456
2457        ws.stream
2458            .memcpy_htod(
2459                beta0_host.as_slice().ok_or("beta0 not contiguous")?,
2460                &mut loop_ws.beta_dev,
2461            )
2462            .map_err(|e| format!("upload beta0: {e}"))?;
2463
2464        let backend = crate::gpu_kernels::pirls_row::PirlsRowBackend::probe()
2465            .map_err(|e| format!("pirls_row backend: {e}"))?;
2466        let loop_module = PIRLS_LOOP_CACHE
2467            .get_or_compile(&shared.ctx, "pirls_loop", PIRLS_LOOP_PTX_SOURCE)
2468            .map_err(|e| format!("pirls loop module: {e}"))?;
2469        let axpy_func = loop_module
2470            .load_function("axpy_n")
2471            .map_err(|e| format!("load axpy_n: {e}"))?;
2472        let sum_func = loop_module
2473            .load_function("deviance_sum")
2474            .map_err(|e| format!("load deviance_sum: {e}"))?;
2475        let linf_func = loop_module
2476            .load_function("linf_norm")
2477            .map_err(|e| format!("load linf_norm: {e}"))?;
2478        let status_first_func = loop_module
2479            .load_function("status_first")
2480            .map_err(|e| format!("load status_first: {e}"))?;
2481        let status_first_ladder_func = loop_module
2482            .load_function("status_first_ladder")
2483            .map_err(|e| format!("load status_first_ladder: {e}"))?;
2484
2485        // beta_orig = Qs · beta  (transforms from transformed to original coords).
2486        // For identity Qs, this is a copy; always goes through ws.beta_orig_dev.
2487        gemv_no_trans(
2488            &ws.blas,
2489            p,
2490            p,
2491            &ws.qs_dev,
2492            &loop_ws.beta_dev,
2493            &mut ws.beta_orig_dev,
2494        )?;
2495        // η = X_original · beta_orig  then η += offset (#258).
2496        gemv_no_trans(
2497            &ws.blas,
2498            n,
2499            p,
2500            &shared.x_original_dev,
2501            &ws.beta_orig_dev,
2502            &mut loop_ws.eta_dev,
2503        )?;
2504        axpy(
2505            &ws.stream,
2506            &axpy_func,
2507            1.0,
2508            &shared.offset_dev,
2509            &mut loop_ws.eta_dev,
2510            n,
2511        )?;
2512        // Initial solve-row pass on the starting η (4-output kernel only).
2513        crate::gpu_kernels::pirls_row::launch_solve_row_on_stream(
2514            backend,
2515            family,
2516            curvature,
2517            gamma_shape,
2518            &ws.stream,
2519            n,
2520            &loop_ws.eta_dev,
2521            &shared.y_dev,
2522            &shared.prior_w_dev,
2523            &mut loop_ws.row_solve,
2524        )
2525        .map_err(|e| format!("solve-row init: {e}"))?;
2526        certify_device_rows(
2527            &ws.stream,
2528            &status_first_func,
2529            &loop_ws.row_solve.status,
2530            &mut loop_ws.status_u32_dev,
2531            family,
2532            curvature,
2533            gamma_shape,
2534            &loop_ws.eta_dev,
2535            &shared.y_dev,
2536            &shared.prior_w_dev,
2537            n,
2538            "solve-row init",
2539        )?;
2540
2541        let mut prev_deviance = reduce_scalar(
2542            &ws.stream,
2543            &sum_func,
2544            &loop_ws.row_solve.deviance,
2545            n,
2546            &mut loop_ws.scalar_dev,
2547            "deviance_init",
2548        )?;
2549        let mut last_logdet = 0.0_f64;
2550        let mut converged = false;
2551
2552        // Host-side mirror of `beta_dev`. Maintained in lock-step with
2553        // every accepted Newton step so we can evaluate the
2554        // shifted-quadratic penalty `βᵀSβ − 2βᵀlinear_shift +
2555        // constant_shift` on the host without an extra `β` DtoH per
2556        // iteration. The initial state is `beta0_host` verbatim.
2557        let mut beta_host: Array1<f64> = beta0_host.to_owned();
2558
2559        // Initial *penalized* objective = data-deviance(β₀) + shifted
2560        // quadratic(β₀). This is the value the line search and
2561        // convergence test compare candidates against — matches the CPU
2562        // oracle's `penalized_objective` in `CandidateScreen`.
2563        let s_beta0 = penalty_hessian.dot(&beta_host);
2564        let penalty_init =
2565            beta_host.dot(&s_beta0) - 2.0 * beta_host.dot(&linear_shift) + constant_shift;
2566        let mut prev_objective = prev_deviance + penalty_init;
2567
2568        // Diagnostic scalars surfaced on the outcome so the dispatch
2569        // wirer can populate WorkingModelPirlsResult / PirlsResult
2570        // fields without re-running the loop. They mirror the CPU
2571        // oracle's per-iter tracking in runworking_model_pirls; the
2572        // "deviance change" diagnostic now carries the *penalized*
2573        // objective delta (matches the CPU oracle's convergence-test
2574        // input and what the issue requested).
2575        let mut last_dev_delta = 0.0_f64;
2576        let mut last_halving: usize = 0;
2577        let mut last_step_size = 0.0_f64;
2578        let mut min_dev = prev_deviance;
2579        let mut step_search_exhausted = false;
2580
2581        for it in 0..max_iter {
2582            last_logdet = solve_step_on_stream_device_inplace(
2583                shared,
2584                ws,
2585                PirlsStepStreamDeviceInput {
2586                    w_solver_dev: &loop_ws.row_solve.w_solver,
2587                    grad_eta_dev: &loop_ws.row_solve.grad_eta,
2588                    penalty_hessian,
2589                    step_lm_lambda: lm_ridge,
2590                    objective_ridge,
2591                    beta_dev: &loop_ws.beta_dev,
2592                    linear_shift,
2593                },
2594            )
2595            .map_err(|e| format!("inner step it={it}: {e}"))?;
2596            // ws.rhs_dev holds the Newton descent direction δ = H⁻¹·rhs (#257).
2597            // Copy device-to-device: no host round-trip.
2598            ws.stream
2599                .memcpy_dtod(&ws.rhs_dev, &mut loop_ws.direction_dev)
2600                .map_err(|e| format!("direction d2d copy it={it}: {e}"))?;
2601
2602            let dir_linf = reduce_scalar(
2603                &ws.stream,
2604                &linf_func,
2605                &loop_ws.direction_dev,
2606                p,
2607                &mut loop_ws.scalar_dev,
2608                "dir_linf",
2609            )?;
2610
2611            // dir_orig = Qs · direction (transform direction to original coords).
2612            gemv_no_trans(
2613                &ws.blas,
2614                p,
2615                p,
2616                &ws.qs_dev,
2617                &loop_ws.direction_dev,
2618                &mut ws.dir_orig_dev,
2619            )?;
2620            gemv_no_trans(
2621                &ws.blas,
2622                n,
2623                p,
2624                &shared.x_original_dev,
2625                &ws.dir_orig_dev,
2626                &mut loop_ws.xd_dev,
2627            )?;
2628
2629            // -- Fused alpha-ladder (candidate-objective mode) ----------------
2630            // One kernel launch evaluates eta + alpha_k*xdelta for all k in
2631            // ALPHA_LADDER simultaneously, atomically accumulating per-row
2632            // deviance into objective_dev[k] and writing exact per-row refusal
2633            // codes. A deterministic device reduction returns 7 row/code pairs;
2634            // a scalar-sized DtoH selects the
2635            // accepted step -- no per-alpha kernel launch, no full row-output
2636            // write, no per-alpha host scalar sync.
2637            loop_ws
2638                .alpha_ladder
2639                .zero(&ws.stream)
2640                .map_err(|e| format!("ladder zero it={it}: {e}"))?;
2641            crate::gpu_kernels::pirls_row::launch_alpha_ladder_on_stream(
2642                backend,
2643                family,
2644                curvature,
2645                gamma_shape,
2646                &ws.stream,
2647                n,
2648                &loop_ws.eta_dev,
2649                &loop_ws.xd_dev,
2650                &shared.y_dev,
2651                &shared.prior_w_dev,
2652                &mut loop_ws.alpha_ladder,
2653            )
2654            .map_err(|e| format!("alpha-ladder it={it}: {e}"))?;
2655            let obj_host: Vec<f64> = ws
2656                .stream
2657                .clone_dtoh(&loop_ws.alpha_ladder.objective_dev)
2658                .map_err(|e| format!("ladder dtoh obj it={it}: {e}"))?;
2659            let candidate_refusals = reduce_ladder_status_first(
2660                &ws.stream,
2661                &status_first_ladder_func,
2662                &loop_ws.alpha_ladder.status_dev,
2663                n,
2664                &mut loop_ws.status_u32_dev,
2665            )?;
2666            // Download the direction (p << n; one DtoH per iteration to
2667            // compute the host-side penalty term and maintain beta_host).
2668            let direction_host: Vec<f64> = ws
2669                .stream
2670                .clone_dtoh(&loop_ws.direction_dev)
2671                .map_err(|e| format!("dtoh direction it={it}: {e}"))?;
2672
2673            // Penalized objective for each candidate step:
2674            //   obj_pen[k] = deviance(eta + alpha_k * xd)
2675            //               + (beta + alpha_k * d)^T S (beta + alpha_k * d)
2676            //               - 2 (beta + alpha_k * d) . linear_shift
2677            //               + constant_shift
2678            // The quadratic in alpha expands as:
2679            //   penalty(beta) + alpha * [2 d^T (S beta - linear_shift)]
2680            //                  + alpha^2 * d^T S d
2681            let dir_view = ndarray::aview1(&direction_host);
2682            let sd = penalty_hessian.dot(&dir_view);
2683            let s_beta = penalty_hessian.dot(&beta_host);
2684            let dtsd = dir_view.dot(&sd);
2685            let linear_coeff = 2.0 * dir_view.dot(&(&s_beta - &linear_shift));
2686            let penalty_beta =
2687                beta_host.dot(&s_beta) - 2.0 * beta_host.dot(&linear_shift) + constant_shift;
2688
2689            let mut alpha = 0.0_f64;
2690            let mut accepted_dev = prev_deviance;
2691            let mut accepted_objective = prev_objective;
2692            let mut halving_count: usize = 0;
2693            for (k, &dev_k) in obj_host.iter().enumerate() {
2694                let a = crate::gpu_kernels::pirls_row::ALPHA_LADDER[k];
2695                let pen_k = penalty_beta + a * linear_coeff + a * a * dtsd;
2696                let obj_k = dev_k + pen_k;
2697                // Match the CPU oracle's acceptance test (#263):
2698                // `<= prev_objective` is the `CandidateScreen`
2699                // criterion — a step that holds the penalized
2700                // objective steady (e.g. an exact zero-gradient
2701                // direction) must still be accepted so the line
2702                // search does not spuriously exhaust at a
2703                // stationary point.
2704                if candidate_refusals[k].is_none() && obj_k.is_finite() && obj_k <= prev_objective {
2705                    alpha = a;
2706                    accepted_dev = dev_k;
2707                    accepted_objective = obj_k;
2708                    halving_count = k;
2709                    break;
2710                }
2711            }
2712            if alpha == 0.0 {
2713                if candidate_refusals.iter().all(Option::is_some) {
2714                    let (row, code) = candidate_refusals[0]
2715                        .expect("all alpha-ladder candidates were certified as refusals");
2716                    let eta_host = ws
2717                        .stream
2718                        .clone_dtoh(&loop_ws.eta_dev)
2719                        .map_err(|error| format!("ladder refusal eta download: {error}"))?;
2720                    let xd_host = ws
2721                        .stream
2722                        .clone_dtoh(&loop_ws.xd_dev)
2723                        .map_err(|error| format!("ladder refusal direction download: {error}"))?;
2724                    let y_host = ws
2725                        .stream
2726                        .clone_dtoh(&shared.y_dev)
2727                        .map_err(|error| format!("ladder refusal response download: {error}"))?;
2728                    let prior_host =
2729                        ws.stream.clone_dtoh(&shared.prior_w_dev).map_err(|error| {
2730                            format!("ladder refusal prior-weight download: {error}")
2731                        })?;
2732                    let trial_eta = eta_host[row]
2733                        + crate::gpu_kernels::pirls_row::ALPHA_LADDER[0] * xd_host[row];
2734                    return Err(replay_row_refusal(
2735                        family,
2736                        curvature,
2737                        gamma_shape,
2738                        row,
2739                        code,
2740                        trial_eta,
2741                        y_host[row],
2742                        prior_host[row],
2743                    ));
2744                }
2745                // No α in the ladder produced a step lowering the
2746                // *penalized* objective. The previous code (and the
2747                // first draft of this rewrite) silently committed
2748                // α=1 here and merely *flagged* exhaustion — that
2749                // still commits a non-descent step, which is exactly
2750                // what the issue forbids (#263).
2751                //
2752                // Signal exhaustion and exit the inner loop without
2753                // committing β / η / solve-row buffers;
2754                // `build_loop_outcome` then maps
2755                // `step_search_exhausted` to
2756                // `PirlsStatus::LmStepSearchExhausted`, exactly the
2757                // CPU oracle's "no acceptable step direction even
2758                // after damping" signal. The outer REML / LM
2759                // controller can raise damping or reject the outer
2760                // iteration. β / η / prev_deviance / prev_objective
2761                // all stay at their last accepted values; the
2762                // device buffers are likewise untouched.
2763                step_search_exhausted = true;
2764                last_halving = 0;
2765                last_step_size = 0.0;
2766                last_dev_delta = 0.0;
2767                break;
2768            }
2769            step_search_exhausted = false;
2770            // Commit accepted step: beta and eta updated in-place.
2771            axpy(
2772                &ws.stream,
2773                &axpy_func,
2774                alpha,
2775                &loop_ws.direction_dev,
2776                &mut loop_ws.beta_dev,
2777                p,
2778            )?;
2779            axpy(
2780                &ws.stream,
2781                &axpy_func,
2782                alpha,
2783                &loop_ws.xd_dev,
2784                &mut loop_ws.eta_dev,
2785                n,
2786            )?;
2787            // Maintain host-side beta mirror: beta_host += alpha * direction.
2788            for (b, &d) in beta_host.iter_mut().zip(direction_host.iter()) {
2789                *b += alpha * d;
2790            }
2791            // Refresh the 4-output solve-row buffers for the next Newton iter.
2792            crate::gpu_kernels::pirls_row::launch_solve_row_on_stream(
2793                backend,
2794                family,
2795                curvature,
2796                gamma_shape,
2797                &ws.stream,
2798                n,
2799                &loop_ws.eta_dev,
2800                &shared.y_dev,
2801                &shared.prior_w_dev,
2802                &mut loop_ws.row_solve,
2803            )
2804            .map_err(|e| format!("solve-row accepted it={it}: {e}"))?;
2805            certify_device_rows(
2806                &ws.stream,
2807                &status_first_func,
2808                &loop_ws.row_solve.status,
2809                &mut loop_ws.status_u32_dev,
2810                family,
2811                curvature,
2812                gamma_shape,
2813                &loop_ws.eta_dev,
2814                &shared.y_dev,
2815                &shared.prior_w_dev,
2816                n,
2817                "solve-row accepted",
2818            )?;
2819
2820            let step_norm = alpha.abs() * dir_linf;
2821            let dev_delta = (prev_objective - accepted_objective).abs();
2822            last_dev_delta = dev_delta;
2823            last_halving = halving_count;
2824            last_step_size = alpha;
2825            if accepted_dev < min_dev {
2826                min_dev = accepted_dev;
2827            }
2828
2829            prev_deviance = accepted_dev;
2830            prev_objective = accepted_objective;
2831
2832            if dir_linf <= tol
2833                && step_norm <= tol
2834                && dev_delta <= tol * (1.0 + prev_objective.abs())
2835            {
2836                converged = true;
2837                // Final-row mode: write the full production row surface once.
2838                crate::gpu_kernels::pirls_row::launch_row_reweight_on_stream(
2839                    backend,
2840                    family,
2841                    curvature,
2842                    gamma_shape,
2843                    &ws.stream,
2844                    n,
2845                    &loop_ws.eta_dev,
2846                    &shared.y_dev,
2847                    &shared.prior_w_dev,
2848                    &mut loop_ws.row_final,
2849                )
2850                .map_err(|e| format!("final-row converged: {e}"))?;
2851                certify_device_rows(
2852                    &ws.stream,
2853                    &status_first_func,
2854                    &loop_ws.row_final.status,
2855                    &mut loop_ws.status_u32_dev,
2856                    family,
2857                    curvature,
2858                    gamma_shape,
2859                    &loop_ws.eta_dev,
2860                    &shared.y_dev,
2861                    &shared.prior_w_dev,
2862                    n,
2863                    "final-row converged",
2864                )?;
2865                let h_final = rebuild_h_final(
2866                    shared,
2867                    ws,
2868                    &loop_ws.row_final.w_hessian,
2869                    penalty_hessian,
2870                    objective_ridge,
2871                )
2872                .map_err(|e| format!("rebuild H_final (converged): {e}"))?;
2873                return build_loop_outcome(
2874                    ws,
2875                    loop_ws,
2876                    h_final,
2877                    last_logdet,
2878                    prev_deviance,
2879                    it + 1,
2880                    converged,
2881                    lm_ridge,
2882                    objective_ridge,
2883                    extra,
2884                    LoopDiagnostics {
2885                        last_deviance_change: last_dev_delta,
2886                        last_step_halving: last_halving,
2887                        last_step_size,
2888                        min_deviance: min_dev,
2889                        step_search_exhausted,
2890                    },
2891                );
2892            }
2893        }
2894
2895        // Final-row mode: write the full production row surface once at exit.
2896        crate::gpu_kernels::pirls_row::launch_row_reweight_on_stream(
2897            backend,
2898            family,
2899            curvature,
2900            gamma_shape,
2901            &ws.stream,
2902            n,
2903            &loop_ws.eta_dev,
2904            &shared.y_dev,
2905            &shared.prior_w_dev,
2906            &mut loop_ws.row_final,
2907        )
2908        .map_err(|e| format!("final-row max_iter: {e}"))?;
2909        certify_device_rows(
2910            &ws.stream,
2911            &status_first_func,
2912            &loop_ws.row_final.status,
2913            &mut loop_ws.status_u32_dev,
2914            family,
2915            curvature,
2916            gamma_shape,
2917            &loop_ws.eta_dev,
2918            &shared.y_dev,
2919            &shared.prior_w_dev,
2920            n,
2921            "final-row max_iter",
2922        )?;
2923        let h_final = rebuild_h_final(
2924            shared,
2925            ws,
2926            &loop_ws.row_final.w_hessian,
2927            penalty_hessian,
2928            objective_ridge,
2929        )
2930        .map_err(|e| format!("rebuild H_final (max_iter): {e}"))?;
2931        build_loop_outcome(
2932            ws,
2933            loop_ws,
2934            h_final,
2935            last_logdet,
2936            prev_deviance,
2937            max_iter,
2938            converged,
2939            lm_ridge,
2940            objective_ridge,
2941            extra,
2942            LoopDiagnostics {
2943                last_deviance_change: last_dev_delta,
2944                last_step_halving: last_halving,
2945                last_step_size,
2946                min_deviance: min_dev,
2947                step_search_exhausted,
2948            },
2949        )
2950    }
2951
2952    /// Internal carrier for the scalar diagnostics tracked across the
2953    /// inner Newton loop. Surfaced verbatim on `PirlsLoopOutcome` so the
2954    /// dispatch wirer's plumbing to `WorkingModelPirlsResult` is a
2955    /// direct field copy.
2956    ///
2957    /// `step_search_exhausted` is the GPU mirror of the CPU oracle's
2958    /// `PirlsStatus::LmStepSearchExhausted` signal: the line-search
2959    /// halving ladder produced no step that lowered the *penalized*
2960    /// objective. When true, `build_loop_outcome` promotes the emitted
2961    /// status accordingly so the outer REML / LM controller can raise
2962    /// damping or fail the iteration cleanly instead of being handed a
2963    /// silently non-descent step.
2964    struct LoopDiagnostics {
2965        last_deviance_change: f64,
2966        last_step_halving: usize,
2967        last_step_size: f64,
2968        min_deviance: f64,
2969        step_search_exhausted: bool,
2970    }
2971
2972    /// Build a full-surface [`PirlsLoopOutcome`] from the loop's
2973    /// device-resident state plus optional caller-supplied
2974    /// [`PirlsLoopExtra`] context.
2975    ///
2976    /// Five n-vector DtoH downloads are unavoidable (η, μ, grad_η,
2977    /// w_hessian, w_solver); β is one p-vector download. When `extra`
2978    /// is `Some`, the host-side helpers
2979    /// `computeworkingweight_derivatives_from_eta` and (optionally)
2980    /// `compute_observed_hessian_curvature_arrays` produce the
2981    /// solve-side aux jets and the curvature-promoted Hessian-side
2982    /// weights; `compute_constraint_kkt_diagnostics` runs over the
2983    /// converged β and reconstructed penalised gradient. All of this
2984    /// is bit-identical to the corresponding CPU oracle code paths in
2985    /// `fit_model_for_fixed_rho_with_adaptive_kkt`.
2986    fn build_loop_outcome(
2987        ws: &mut SigmaPirlsGpuWorkspace,
2988        loop_ws: &mut PirlsLoopWorkspace,
2989        penalized_hessian: Array2<f64>,
2990        logdet: f64,
2991        deviance: f64,
2992        iterations: usize,
2993        converged: bool,
2994        step_lm_lambda: f64,
2995        objective_ridge: f64,
2996        extra: Option<&PirlsLoopExtra<'_>>,
2997        diagnostics: LoopDiagnostics,
2998    ) -> Result<PirlsLoopOutcome, PirlsGpuLoopError> {
2999        let beta = download_vec(&ws.stream, &loop_ws.beta_dev)?;
3000        let final_eta = download_vec(&ws.stream, &loop_ws.eta_dev)?;
3001        let final_mu = download_vec(&ws.stream, &loop_ws.row_final.mu)?;
3002        let final_grad_eta = download_vec(&ws.stream, &loop_ws.row_final.grad_eta)?;
3003        let final_w_hessian = download_vec(&ws.stream, &loop_ws.row_final.w_hessian)?;
3004        let final_w_solver = download_vec(&ws.stream, &loop_ws.row_final.w_solver)?;
3005
3006        // Stability classification — Unstable supersedes both
3007        // converged and MaxIterationsReached because a non-finite η /
3008        // μ at the accepted step means the line search swallowed a
3009        // divergence (saturated likelihood / perfect separation).
3010        let eta_finite = final_eta.iter().all(|v| v.is_finite());
3011        let mu_finite = final_mu.iter().all(|v| v.is_finite());
3012        let beta_finite = beta.iter().all(|v| v.is_finite());
3013        let stability_ok = eta_finite && mu_finite && beta_finite;
3014        let status = if !stability_ok {
3015            crate::pirls::PirlsStatus::Unstable
3016        } else if converged {
3017            crate::pirls::PirlsStatus::Converged
3018        } else if diagnostics.step_search_exhausted {
3019            // The α-ladder produced no step lowering the *penalized*
3020            // objective — exactly the CPU oracle's "no acceptable step
3021            // direction even after damping" signal. Distinct from the
3022            // iteration-cap exhaustion (MaxIterationsReached) so the
3023            // outer REML / LM controller can react (raise damping / try
3024            // a different curvature) rather than silently accepting an
3025            // ascent step.
3026            crate::pirls::PirlsStatus::LmStepSearchExhausted
3027        } else {
3028            crate::pirls::PirlsStatus::MaxIterationsReached
3029        };
3030
3031        // RidgePassport is built from objective_ridge only — step_lm_lambda
3032        // is a solve-only artefact and must never contaminate EDF / REML.
3033        let default_ridge = gam_problem::RidgePassport::scaled_identity(
3034            objective_ridge,
3035            gam_linalg::RidgePolicy::exact_full_objective(),
3036        )
3037        .map_err(gam_problem::EstimationError::from)?;
3038
3039        let max_abs_eta = final_eta.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
3040
3041        match extra {
3042            Some(ext) => {
3043                // Family aux jets at the converged η — bit-identical
3044                // to the CPU oracle's post-convergence finalization.
3045                let (score_c, score_d, solve_dmu_deta, solve_d2mu_deta2, solve_d3mu_deta3) =
3046                    crate::pirls::computeworkingweight_derivatives_from_eta(
3047                        ext.likelihood,
3048                        ext.inverse_link,
3049                        &final_eta,
3050                        ext.priorweights,
3051                    )
3052                    .map_err(PirlsGpuLoopError::Geometry)?;
3053
3054                let (finalweights, solve_c_array, solve_d_array) = match ext.exported_curvature {
3055                    crate::pirls::HessianCurvatureKind::Observed => {
3056                        crate::pirls::compute_observed_hessian_curvature_arrays(
3057                            ext.likelihood,
3058                            ext.inverse_link,
3059                            &final_eta,
3060                            ext.y,
3061                            &final_w_solver,
3062                            ext.priorweights,
3063                        )
3064                        .map_err(PirlsGpuLoopError::Geometry)?
3065                    }
3066                    crate::pirls::HessianCurvatureKind::Fisher => {
3067                        (final_w_solver.clone(), score_c.clone(), score_d.clone())
3068                    }
3069                };
3070
3071                // The GPU loop solves in the transformed design X·Qs, so
3072                // the loop's β is already in transformed coordinates.
3073                // beta_original = qs · beta_transformed (not applied here;
3074                // callers that need original coordinates compute it from
3075                // reparam_result.qs per the PirlsResult contract).
3076                let beta_transformed = beta.clone();
3077
3078                let constraint_kkt = ext.linear_constraints.and_then(|lin| {
3079                    if lin.a.nrows() == 0 {
3080                        return None;
3081                    }
3082                    // Reconstruct the penalised gradient at the
3083                    // converged β: g = Xᵀ(grad_eta) + S β + objective_ridge·β.
3084                    // `penalized_hessian` is already XᵀWX + S + objective_ridge·I
3085                    // (step_lm_lambda was stripped from the export), so
3086                    // H_pen·β ≈ Xᵀ·grad_eta at a KKT-feasible solution.
3087                    let grad = penalized_hessian.dot(&beta);
3088                    Some(crate::active_set::compute_constraint_kkt_diagnostics(
3089                        &beta, &grad, lin,
3090                    ))
3091                });
3092
3093                let ridge_passport = ext.ridge_passport.unwrap_or(default_ridge);
3094                let firth = ext
3095                    .firth
3096                    .clone()
3097                    .unwrap_or(crate::pirls::FirthDiagnostics::Inactive);
3098                let edf = ext.edf.unwrap_or(f64::NAN);
3099                // Mirrors CPU oracle's invariant: when
3100                // `computeworkingweight_derivatives_from_eta` returns
3101                // Ok, all five jets are real (not placeholders), so
3102                // this field is `false`. See
3103                // `src/solver/pirls.rs:6634`.
3104                let derivatives_unsupported = false;
3105
3106                Ok(PirlsLoopOutcome {
3107                    beta,
3108                    penalized_hessian,
3109                    logdet,
3110                    deviance,
3111                    iterations,
3112                    converged,
3113                    final_eta,
3114                    final_mu,
3115                    final_grad_eta,
3116                    final_w_hessian,
3117                    final_w_solver: final_w_solver.clone(),
3118                    final_offset: ext.offset.to_owned(),
3119                    beta_transformed,
3120                    finalweights,
3121                    solveweights: final_w_solver,
3122                    solve_dmu_deta,
3123                    solve_d2mu_deta2,
3124                    solve_d3mu_deta3,
3125                    solve_c_array,
3126                    solve_d_array,
3127                    derivatives_unsupported,
3128                    status,
3129                    ridge_passport,
3130                    firth,
3131                    constraint_kkt,
3132                    edf,
3133                    last_deviance_change: diagnostics.last_deviance_change,
3134                    last_step_halving: diagnostics.last_step_halving,
3135                    last_step_size: diagnostics.last_step_size,
3136                    final_lm_lambda: step_lm_lambda,
3137                    min_deviance: diagnostics.min_deviance,
3138                    max_abs_eta,
3139                })
3140            }
3141            None => {
3142                // No extra context — pirls-dispatch-wirer can do the
3143                // derived-field plumbing host-side if needed. We give
3144                // it `solveweights = final_w_solver` echoed through,
3145                // empty arrays everywhere else, and safe default
3146                // status / passport / firth so the struct is fully
3147                // populated and the wirer's match arms can rely on
3148                // every field being present.
3149                Ok(PirlsLoopOutcome {
3150                    beta: beta.clone(),
3151                    penalized_hessian,
3152                    logdet,
3153                    deviance,
3154                    iterations,
3155                    converged,
3156                    final_eta,
3157                    final_mu,
3158                    final_grad_eta,
3159                    final_w_hessian,
3160                    final_w_solver: final_w_solver.clone(),
3161                    final_offset: Array1::<f64>::zeros(0),
3162                    beta_transformed: beta,
3163                    finalweights: Array1::<f64>::zeros(0),
3164                    solveweights: final_w_solver,
3165                    solve_dmu_deta: Array1::<f64>::zeros(0),
3166                    solve_d2mu_deta2: Array1::<f64>::zeros(0),
3167                    solve_d3mu_deta3: Array1::<f64>::zeros(0),
3168                    solve_c_array: Array1::<f64>::zeros(0),
3169                    solve_d_array: Array1::<f64>::zeros(0),
3170                    derivatives_unsupported: true,
3171                    status,
3172                    ridge_passport: default_ridge,
3173                    firth: crate::pirls::FirthDiagnostics::Inactive,
3174                    constraint_kkt: None,
3175                    edf: f64::NAN,
3176                    last_deviance_change: diagnostics.last_deviance_change,
3177                    last_step_halving: diagnostics.last_step_halving,
3178                    last_step_size: diagnostics.last_step_size,
3179                    final_lm_lambda: step_lm_lambda,
3180                    min_deviance: diagnostics.min_deviance,
3181                    max_abs_eta,
3182                })
3183            }
3184        }
3185    }
3186
3187    fn gemv_no_trans(
3188        blas: &CudaBlas,
3189        n: usize,
3190        p: usize,
3191        a_dev: &CudaSlice<f64>,
3192        x_dev: &CudaSlice<f64>,
3193        y_dev: &mut CudaSlice<f64>,
3194    ) -> Result<(), String> {
3195        let n_i = to_i32(n)?;
3196        let p_i = to_i32(p)?;
3197        let cfg = GemvConfig::<f64> {
3198            trans: cublasOperation_t::CUBLAS_OP_N,
3199            m: n_i,
3200            n: p_i,
3201            alpha: 1.0,
3202            lda: n_i,
3203            incx: 1,
3204            beta: 0.0,
3205            incy: 1,
3206        };
3207        // SAFETY: a is n×p col-major lda=n; x length p incx=1; y length n incy=1.
3208        unsafe { blas.gemv(cfg, a_dev, x_dev, y_dev) }.map_err(|e| format!("dgemv no-trans: {e}"))
3209    }
3210
3211    fn axpy(
3212        stream: &std::sync::Arc<cudarc::driver::CudaStream>,
3213        func: &cudarc::driver::CudaFunction,
3214        alpha: f64,
3215        x_dev: &CudaSlice<f64>,
3216        y_dev: &mut CudaSlice<f64>,
3217        n: usize,
3218    ) -> Result<(), String> {
3219        const THREADS: u32 = 256;
3220        let n_i = to_i32(n)?;
3221        let n_u = u32::try_from(n).map_err(|_| format!("axpy n={n} > u32"))?;
3222        let grid = n_u.div_ceil(THREADS).max(1);
3223        let cfg = LaunchConfig {
3224            grid_dim: (grid, 1, 1),
3225            block_dim: (THREADS, 1, 1),
3226            shared_mem_bytes: 0,
3227        };
3228        let mut builder = stream.launch_builder(func);
3229        builder.arg(&alpha);
3230        builder.arg(x_dev);
3231        builder.arg(y_dev);
3232        builder.arg(&n_i);
3233        // SAFETY: axpy_n signature is (double, const double*, double*, int);
3234        // both vectors length n.
3235        unsafe { builder.launch(cfg) }
3236            .map(|_event_pair| ())
3237            .map_err(|e| format!("axpy launch: {e}"))
3238    }
3239
3240    fn reduce_scalar(
3241        stream: &std::sync::Arc<cudarc::driver::CudaStream>,
3242        func: &cudarc::driver::CudaFunction,
3243        src: &CudaSlice<f64>,
3244        len: usize,
3245        scalar_dev: &mut CudaSlice<f64>,
3246        label: &'static str,
3247    ) -> Result<f64, String> {
3248        const THREADS: u32 = 1024;
3249        let len_i = to_i32(len)?;
3250        let cfg = LaunchConfig {
3251            grid_dim: (1, 1, 1),
3252            block_dim: (THREADS, 1, 1),
3253            shared_mem_bytes: 0,
3254        };
3255        let mut builder = stream.launch_builder(func);
3256        builder.arg(src);
3257        builder.arg(&len_i);
3258        builder.arg(&mut *scalar_dev);
3259        // SAFETY: kernel signature (const double*, int, double*). The
3260        // `&mut *scalar_dev` reborrow keeps `scalar_dev` available for the
3261        // download below.
3262        unsafe { builder.launch(cfg) }.map_err(|e| format!("{label} reduce launch: {e}"))?;
3263        let host = stream
3264            .clone_dtoh(scalar_dev)
3265            .map_err(|e| format!("download {label}: {e}"))?;
3266        Ok(host[0])
3267    }
3268
3269    /// Deterministically select the smallest non-zero row status with one
3270    /// scalar-sized transfer.  Outputs `(row, refusal_code)` or `None`.
3271    fn reduce_status_first(
3272        stream: &std::sync::Arc<cudarc::driver::CudaStream>,
3273        func: &cudarc::driver::CudaFunction,
3274        src: &CudaSlice<u32>,
3275        len: usize,
3276        status_dev: &mut CudaSlice<u32>,
3277        label: &'static str,
3278    ) -> Result<Option<(usize, u32)>, String> {
3279        const THREADS: u32 = 1024;
3280        let len_i = to_i32(len)?;
3281        let cfg = LaunchConfig {
3282            grid_dim: (1, 1, 1),
3283            block_dim: (THREADS, 1, 1),
3284            shared_mem_bytes: 0,
3285        };
3286        let mut builder = stream.launch_builder(func);
3287        builder.arg(src);
3288        builder.arg(&len_i);
3289        builder.arg(&mut *status_dev);
3290        // SAFETY: status_first kernel signature (const unsigned int*, int,
3291        // unsigned int*). The output has at least two u32 slots.
3292        unsafe { builder.launch(cfg) }.map_err(|e| format!("{label} first reduce launch: {e}"))?;
3293        let host = stream
3294            .clone_dtoh(status_dev)
3295            .map_err(|e| format!("download {label}: {e}"))?;
3296        if host[0] == u32::MAX {
3297            Ok(None)
3298        } else {
3299            Ok(Some((host[0] as usize, host[1])))
3300        }
3301    }
3302
3303    /// Reduce the alpha-major `[7*n]` status matrix in one seven-block launch.
3304    fn reduce_ladder_status_first(
3305        stream: &std::sync::Arc<cudarc::driver::CudaStream>,
3306        func: &cudarc::driver::CudaFunction,
3307        src: &CudaSlice<u32>,
3308        n: usize,
3309        status_dev: &mut CudaSlice<u32>,
3310    ) -> Result<[Option<(usize, u32)>; crate::gpu_kernels::pirls_row::ALPHA_LADDER_LEN], String>
3311    {
3312        const THREADS: u32 = 1024;
3313        let n_i = to_i32(n)?;
3314        let cfg = LaunchConfig {
3315            grid_dim: (crate::gpu_kernels::pirls_row::ALPHA_LADDER_LEN as u32, 1, 1),
3316            block_dim: (THREADS, 1, 1),
3317            shared_mem_bytes: 0,
3318        };
3319        let mut builder = stream.launch_builder(func);
3320        builder.arg(src);
3321        builder.arg(&n_i);
3322        builder.arg(&mut *status_dev);
3323        // SAFETY: status_first_ladder signature is (const u32*, int, u32*);
3324        // status_dev owns 14 slots (seven rows followed by seven codes).
3325        unsafe { builder.launch(cfg) }
3326            .map_err(|e| format!("alpha-ladder status reduction launch: {e}"))?;
3327        let host = stream
3328            .clone_dtoh(status_dev)
3329            .map_err(|e| format!("download alpha-ladder status summary: {e}"))?;
3330        let mut result = [None; crate::gpu_kernels::pirls_row::ALPHA_LADDER_LEN];
3331        for k in 0..crate::gpu_kernels::pirls_row::ALPHA_LADDER_LEN {
3332            if host[k] != u32::MAX {
3333                result[k] = Some((
3334                    host[k] as usize,
3335                    host[crate::gpu_kernels::pirls_row::ALPHA_LADDER_LEN + k],
3336                ));
3337            }
3338        }
3339        Ok(result)
3340    }
3341
3342    fn replay_row_refusal(
3343        family: crate::gpu_kernels::pirls_row::PirlsRowFamily,
3344        curvature: crate::gpu_kernels::pirls_row::CurvatureMode,
3345        gamma_shape: f64,
3346        row: usize,
3347        code: u32,
3348        eta: f64,
3349        y: f64,
3350        prior_weight: f64,
3351    ) -> PirlsGpuLoopError {
3352        let input = crate::gpu_kernels::pirls_row::RowInput {
3353            eta,
3354            y,
3355            prior_weight,
3356        };
3357        match crate::gpu_kernels::pirls_row::row_reweight_cpu_at(
3358            row,
3359            family,
3360            curvature,
3361            input,
3362            gamma_shape,
3363        ) {
3364            Err(error) => PirlsGpuLoopError::Geometry(error),
3365            Ok(_) => PirlsGpuLoopError::Geometry(
3366                gam_problem::EstimationError::PirlsRowGeometryUnrepresentable {
3367                    row,
3368                    quantity: crate::gpu_kernels::pirls_row::status_codes::quantity(code),
3369                    eta,
3370                    value: f64::from(code),
3371                },
3372            ),
3373        }
3374    }
3375
3376    fn certify_device_rows(
3377        stream: &std::sync::Arc<cudarc::driver::CudaStream>,
3378        status_first_func: &cudarc::driver::CudaFunction,
3379        status: &CudaSlice<u32>,
3380        status_scratch: &mut CudaSlice<u32>,
3381        family: crate::gpu_kernels::pirls_row::PirlsRowFamily,
3382        curvature: crate::gpu_kernels::pirls_row::CurvatureMode,
3383        gamma_shape: f64,
3384        eta: &CudaSlice<f64>,
3385        y: &CudaSlice<f64>,
3386        prior_weight: &CudaSlice<f64>,
3387        n: usize,
3388        label: &'static str,
3389    ) -> Result<(), PirlsGpuLoopError> {
3390        let Some((_row, _code)) =
3391            reduce_status_first(stream, status_first_func, status, n, status_scratch, label)?
3392        else {
3393            return Ok(());
3394        };
3395        let eta_host = stream
3396            .clone_dtoh(eta)
3397            .map_err(|error| format!("{label} refusal eta download: {error}"))?;
3398        let y_host = stream
3399            .clone_dtoh(y)
3400            .map_err(|error| format!("{label} refusal response download: {error}"))?;
3401        let prior_host = stream
3402            .clone_dtoh(prior_weight)
3403            .map_err(|error| format!("{label} refusal prior-weight download: {error}"))?;
3404        let status_host = stream
3405            .clone_dtoh(status)
3406            .map_err(|error| format!("{label} refusal status download: {error}"))?;
3407        crate::gpu_kernels::pirls_row::replay_first_refusal(
3408            family,
3409            curvature,
3410            gamma_shape,
3411            &eta_host,
3412            &y_host,
3413            &prior_host,
3414            &status_host,
3415        )
3416        .map_err(PirlsGpuLoopError::Geometry)
3417    }
3418
3419    fn download_vec(
3420        stream: &std::sync::Arc<cudarc::driver::CudaStream>,
3421        dev: &CudaSlice<f64>,
3422    ) -> Result<Array1<f64>, String> {
3423        let host = stream
3424            .clone_dtoh(dev)
3425            .map_err(|e| format!("download vec: {e}"))?;
3426        Ok(Array1::from_vec(host))
3427    }
3428
3429    /// Result of one GPU Gaussian exact penalised least-squares solve.
3430    pub struct GaussianPlsResult {
3431        pub beta: Array1<f64>,
3432        pub penalized_hessian: Array2<f64>,
3433        pub logdet: f64,
3434    }
3435
3436    /// Exact GPU PLS for Gaussian-identity: assembles QsT A Qs + S on host,
3437    /// then runs POTRF/POTRS on device.  Replaces the PIRLS loop for this family.
3438    pub fn solve_gaussian_pls_on_stream(
3439        a_orig: ArrayView2<'_, f64>,
3440        b_orig: ArrayView1<'_, f64>,
3441        s_transformed: ArrayView2<'_, f64>,
3442        linear_shift: ArrayView1<'_, f64>,
3443        prior_mean_target: ArrayView1<'_, f64>,
3444        ridge: f64,
3445        qs: Option<ArrayView2<'_, f64>>,
3446    ) -> Result<GaussianPlsResult, String> {
3447        let p = b_orig.len();
3448        if a_orig.dim() != (p, p) {
3449            return Err(format!("A shape {:?} != ({p},{p})", a_orig.dim()));
3450        }
3451        if s_transformed.dim() != (p, p) {
3452            return Err(format!("S shape {:?} != ({p},{p})", s_transformed.dim()));
3453        }
3454        if linear_shift.len() != p {
3455            return Err(format!("linear_shift len {} != p={p}", linear_shift.len()));
3456        }
3457        if prior_mean_target.len() != p {
3458            return Err(format!(
3459                "prior_mean_target len {} != p={p}",
3460                prior_mean_target.len()
3461            ));
3462        }
3463        if let Some(qs_v) = qs {
3464            if qs_v.dim() != (p, p) {
3465                return Err(format!("qs shape {:?} != ({p},{p})", qs_v.dim()));
3466            }
3467        }
3468        let (h_rotated, rhs_base) = if let Some(qs_v) = qs {
3469            let qs_owned = qs_v.to_owned();
3470            let tmp = a_orig.dot(&qs_owned);
3471            let h = qs_owned.t().dot(&tmp);
3472            let rb = qs_owned.t().dot(&b_orig);
3473            (h, rb)
3474        } else {
3475            (a_orig.to_owned(), b_orig.to_owned())
3476        };
3477        let penalized_hessian: Array2<f64> = &h_rotated + &s_transformed;
3478        let mut regularized = penalized_hessian.clone();
3479        if ridge > 0.0 {
3480            for i in 0..p {
3481                regularized[[i, i]] += ridge;
3482            }
3483        }
3484        let mut rhs_host = rhs_base;
3485        rhs_host += &linear_shift;
3486        if ridge > 0.0 {
3487            rhs_host.scaled_add(ridge, &prior_mean_target);
3488        }
3489        let (ctx, stream) = context_and_stream()?;
3490        let solver = DnHandle::new(stream.clone())
3491            .map_err(|e| format!("cusolver init (gaussian pls): {e}"))?;
3492        let pp = p.checked_mul(p).ok_or("p*p overflow (gaussian pls)")?;
3493        let mut h_dev = stream
3494            .alloc_zeros::<f64>(pp)
3495            .map_err(|e| format!("alloc H (gaussian pls): {e}"))?;
3496        let mut rhs_dev = stream
3497            .alloc_zeros::<f64>(p)
3498            .map_err(|e| format!("alloc rhs (gaussian pls): {e}"))?;
3499        let potrf_lwork_usize = potrf_query_lwork(&solver, &stream, p)?;
3500        let potrf_lwork = i32::try_from(potrf_lwork_usize)
3501            .map_err(|_| "potrf lwork overflow (gaussian pls)".to_string())?;
3502        let mut potrf_work_dev = stream
3503            .alloc_zeros::<f64>(potrf_lwork_usize.max(1))
3504            .map_err(|e| format!("alloc potrf workspace (gaussian pls): {e}"))?;
3505        let mut potrf_info_dev = stream
3506            .alloc_zeros::<i32>(1)
3507            .map_err(|e| format!("alloc potrf info (gaussian pls): {e}"))?;
3508        let mut potrs_info_dev = stream
3509            .alloc_zeros::<i32>(1)
3510            .map_err(|e| format!("alloc potrs info (gaussian pls): {e}"))?;
3511        let reg_col = to_col_major(&regularized);
3512        stream
3513            .memcpy_htod(reg_col.as_ref(), &mut h_dev)
3514            .map_err(|e| format!("upload H (gaussian pls): {e}"))?;
3515        let rhs_slice = rhs_host
3516            .as_slice()
3517            .ok_or("rhs_host not contiguous (gaussian pls)")?;
3518        stream
3519            .memcpy_htod(rhs_slice, &mut rhs_dev)
3520            .map_err(|e| format!("upload rhs (gaussian pls): {e}"))?;
3521        potrf_in_place_reuse(
3522            &solver,
3523            &stream,
3524            p,
3525            potrf_lwork,
3526            &mut h_dev,
3527            &mut potrf_work_dev,
3528            &mut potrf_info_dev,
3529        )?;
3530        potrs_in_place_reuse(
3531            &solver,
3532            &stream,
3533            p,
3534            1,
3535            &h_dev,
3536            &mut rhs_dev,
3537            &mut potrs_info_dev,
3538        )?;
3539        let logdet = cholesky_logdet_device(&stream, &ctx, p, &h_dev)?;
3540        let beta_raw = stream
3541            .clone_dtoh(&rhs_dev)
3542            .map_err(|e| format!("download beta (gaussian pls): {e}"))?;
3543        check_deferred_potrf_info(&stream, &potrf_info_dev)?;
3544        check_deferred_potrs_info(&stream, &potrs_info_dev)?;
3545        Ok(GaussianPlsResult {
3546            beta: Array1::from_vec(beta_raw),
3547            penalized_hessian,
3548            logdet,
3549        })
3550    }
3551}
3552
3553pub fn weighted_crossprod_gpu(
3554    x: ArrayView2<'_, f64>,
3555    weights: ArrayView1<'_, f64>,
3556) -> Result<Array2<f64>, String> {
3557    #[cfg(not(target_os = "linux"))]
3558    {
3559        return cpu_fallback::weighted_crossprod_cpu(x, weights);
3560    }
3561
3562    #[cfg(target_os = "linux")]
3563    {
3564        if gam_gpu::device_runtime::GpuRuntime::resolve(gam_gpu::global_policy())
3565            .map_err(|error| error.to_string())?
3566            .is_none()
3567        {
3568            return cpu_fallback::weighted_crossprod_cpu(x, weights);
3569        }
3570        cuda::weighted_crossprod(x, weights)
3571    }
3572}
3573
3574pub fn solve_pirls_step_gpu(input: PirlsGpuInput<'_>) -> Result<PirlsGpuStep, String> {
3575    #[cfg(not(target_os = "linux"))]
3576    {
3577        return cpu_fallback::solve_step_cpu(input);
3578    }
3579
3580    #[cfg(target_os = "linux")]
3581    {
3582        if gam_gpu::device_runtime::GpuRuntime::resolve(gam_gpu::global_policy())
3583            .map_err(|error| error.to_string())?
3584            .is_none()
3585        {
3586            return cpu_fallback::solve_step_cpu(input);
3587        }
3588        cuda::solve_step(input)
3589    }
3590}
3591
3592/// Upload X_original, y, prior_w, and offset once per model and return a
3593/// shared device-resident handle reused across all ρ / σ points. All four
3594/// arrays must have the same row-count `n`. The shared handle keeps the
3595/// cached per-ordinal `CudaContext` alive so all peer workspaces bind to
3596/// the same context and can interleave on its asynchronous engines.
3597#[cfg(target_os = "linux")]
3598pub fn upload_shared_pirls_gpu(
3599    x: ndarray::ArrayView2<'_, f64>,
3600    y: ndarray::ArrayView1<'_, f64>,
3601    prior_w: ndarray::ArrayView1<'_, f64>,
3602    offset: ndarray::ArrayView1<'_, f64>,
3603) -> Result<PirlsGpuSharedData, String> {
3604    gam_gpu::device_runtime::GpuRuntime::require()
3605        .map_err(|error| format!("cannot upload shared GPU PIRLS data: {error}"))?;
3606    PirlsGpuSharedData::upload_impl(x, y, prior_w, offset)
3607}
3608
3609/// Allocate a per-stream workspace bound to a fresh non-default CUDA
3610/// stream on `shared`'s context. The cuBLAS and cuSOLVER handles are bound
3611/// to the workspace stream so peer workspaces achieve overlapped execution.
3612#[cfg(target_os = "linux")]
3613pub fn allocate_sigma_pirls_workspace(
3614    shared: &PirlsGpuSharedData,
3615) -> Result<SigmaPirlsGpuWorkspace, String> {
3616    SigmaPirlsGpuWorkspace::allocate_impl(shared)
3617}
3618
3619/// Upload the reparameterisation matrix `Qs` (p×p) for the current ρ / σ
3620/// point. Call once per ρ / σ point before calling
3621/// [`pirls_loop_on_stream`]. When no reparameterisation is active, pass an
3622/// identity matrix.
3623#[cfg(target_os = "linux")]
3624pub fn upload_qs_pirls(
3625    ws: &mut SigmaPirlsGpuWorkspace,
3626    qs: ndarray::ArrayView2<'_, f64>,
3627) -> Result<(), String> {
3628    cuda::upload_qs(ws, qs)
3629}
3630
3631/// Upload an identity Qs for the current ρ / σ point. Equivalent to
3632/// [`upload_qs_pirls`] with an identity matrix; avoids host allocation.
3633#[cfg(target_os = "linux")]
3634pub fn upload_qs_identity_pirls(ws: &mut SigmaPirlsGpuWorkspace) -> Result<(), String> {
3635    cuda::upload_qs_identity(ws)
3636}
3637
3638/// Drive one PIRLS Newton step on the workspace's CUDA stream against the
3639/// device-resident shared design matrix. The math is bit-identical to the
3640/// one-shot [`solve_pirls_step_gpu`]; this entry differs only by
3641/// amortising the design upload and the cuBLAS / cuSOLVER handle creation
3642/// across many sigma fits.
3643#[cfg(target_os = "linux")]
3644pub fn solve_pirls_step_on_stream(
3645    shared: &PirlsGpuSharedData,
3646    ws: &mut SigmaPirlsGpuWorkspace,
3647    input: PirlsStepStreamInput<'_>,
3648) -> Result<PirlsGpuStep, String> {
3649    cuda::solve_step_on_stream(shared, ws, input)
3650}
3651
3652/// Stage 3.2 device-input PIRLS step. Reads `w_solver` and `grad_eta`
3653/// from caller-supplied device buffers (typically populated by
3654/// [`crate::gpu_kernels::pirls_row::launch_row_reweight_on_stream`]) instead of
3655/// uploading them from host arrays. Math is bit-identical to
3656/// [`solve_pirls_step_on_stream`]; this entry differs only by skipping
3657/// the per-iter `weights` and `gradient` host-to-device transfers — only
3658/// the small p×p penalty matrix still crosses the host boundary.
3659#[cfg(target_os = "linux")]
3660pub fn solve_pirls_step_on_stream_device(
3661    shared: &PirlsGpuSharedData,
3662    ws: &mut SigmaPirlsGpuWorkspace,
3663    input: PirlsStepStreamDeviceInput<'_, '_>,
3664) -> Result<PirlsGpuStep, String> {
3665    cuda::solve_step_on_stream_device(shared, ws, input)
3666}
3667
3668/// Stage 3.3 device-resident PIRLS loop driver. See
3669/// [`cuda::pirls_loop`] for the full per-iter contract. Only a few
3670/// 1-f64 scalars cross the host boundary per Newton iteration; β and
3671/// the final penalised Hessian are downloaded once at loop exit.
3672///
3673/// `step_lm_lambda` is the Levenberg–Marquardt damping applied to each
3674/// Newton solve only; it never enters the exported `penalized_hessian`,
3675/// `RidgePassport`, EDF, or penalty term.  `objective_ridge` is the
3676/// real model ridge that enters all of those.
3677#[cfg(target_os = "linux")]
3678pub(crate) fn pirls_loop_on_stream(
3679    shared: &PirlsGpuSharedData,
3680    ws: &mut SigmaPirlsGpuWorkspace,
3681    loop_ws: &mut cuda::PirlsLoopWorkspace,
3682    family: crate::gpu_kernels::pirls_row::PirlsRowFamily,
3683    curvature: crate::gpu_kernels::pirls_row::CurvatureMode,
3684    likelihood_scale: PirlsLoopLikelihoodScale,
3685    beta0: ndarray::ArrayView1<'_, f64>,
3686    penalty_hessian: ndarray::ArrayView2<'_, f64>,
3687    // Linear shift `b` for the shifted-quadratic penalty `βᵀSβ−2βᵀb+c`.
3688    // Pass a zero-length or all-zero slice for fits with no prior-mean shift.
3689    linear_shift: ndarray::ArrayView1<'_, f64>,
3690    // Constant shift `c` for the shifted-quadratic penalty. Pass `0.0` when absent.
3691    constant_shift: f64,
3692    step_lm_lambda: f64,
3693    objective_ridge: f64,
3694    max_iter: usize,
3695    tol: f64,
3696    extra: Option<&cuda::PirlsLoopExtra<'_>>,
3697) -> Result<cuda::PirlsLoopOutcome, cuda::PirlsGpuLoopError> {
3698    let gamma_shape = likelihood_scale
3699        .kernel_argument(family)
3700        .map_err(cuda::PirlsGpuLoopError::Runtime)?;
3701    cuda::pirls_loop(
3702        shared,
3703        ws,
3704        loop_ws,
3705        family,
3706        curvature,
3707        gamma_shape,
3708        beta0,
3709        penalty_hessian,
3710        linear_shift,
3711        constant_shift,
3712        step_lm_lambda,
3713        objective_ridge,
3714        max_iter,
3715        tol,
3716        extra,
3717    )
3718}
3719
3720/// Allocate a Stage 3.3 PIRLS loop workspace bound to the same stream
3721/// as `ws` against the shared device-resident design matrix.
3722#[cfg(target_os = "linux")]
3723pub fn allocate_pirls_loop_workspace(
3724    shared: &PirlsGpuSharedData,
3725    ws: &SigmaPirlsGpuWorkspace,
3726) -> Result<cuda::PirlsLoopWorkspace, String> {
3727    cuda::PirlsLoopWorkspace::allocate(shared, &ws.stream)
3728}
3729
3730/// GPU exact penalised least-squares for Gaussian-identity models.
3731///
3732/// Public wrapper around [`cuda::solve_gaussian_pls_on_stream`].  Delegates
3733/// immediately if the CUDA runtime is initialised; returns an error otherwise
3734/// so the caller can fall back to the CPU path.
3735#[cfg(target_os = "linux")]
3736pub fn solve_gaussian_pls_gpu(
3737    a_orig: ndarray::ArrayView2<'_, f64>,
3738    b_orig: ndarray::ArrayView1<'_, f64>,
3739    s_transformed: ndarray::ArrayView2<'_, f64>,
3740    linear_shift: ndarray::ArrayView1<'_, f64>,
3741    prior_mean_target: ndarray::ArrayView1<'_, f64>,
3742    ridge: f64,
3743    qs: Option<ndarray::ArrayView2<'_, f64>>,
3744) -> Result<cuda::GaussianPlsResult, String> {
3745    cuda::solve_gaussian_pls_on_stream(
3746        a_orig,
3747        b_orig,
3748        s_transformed,
3749        linear_shift,
3750        prior_mean_target,
3751        ridge,
3752        qs,
3753    )
3754}
3755
3756/// CPU fallback for the PIRLS-step GPU primitives.  When this build has no
3757/// CUDA runtime probed, the GPU entry points must still return numerically
3758/// correct results so that callers can route a single code path through
3759/// `*_gpu` while the canonical policy layer in `crate::gpu` records whether
3760/// device execution was selected. Returning `Err` here would silently force
3761/// every caller to grow an `if cuda { .. } else { .. }` branch and risk
3762/// drifting away from the GPU formula.
3763mod cpu_fallback {
3764    use super::{PirlsGpuInput, PirlsGpuStep};
3765    use crate::estimate::reml::assembly::xt_diag_x_dense_into;
3766    use faer::Side;
3767    use gam_linalg::faer_ndarray::FaerCholesky;
3768    use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
3769
3770    pub(super) fn weighted_crossprod_cpu(
3771        x: ArrayView2<'_, f64>,
3772        weights: ArrayView1<'_, f64>,
3773    ) -> Result<Array2<f64>, String> {
3774        validate(x, weights)?;
3775        let x_owned = x.to_owned();
3776        let w_owned = weights.to_owned();
3777        let mut scratch = Array2::<f64>::zeros(x_owned.dim());
3778        Ok(xt_diag_x_dense_into(&x_owned, &w_owned, &mut scratch))
3779    }
3780
3781    pub(super) fn solve_step_cpu(input: PirlsGpuInput<'_>) -> Result<PirlsGpuStep, String> {
3782        validate(input.x, input.weights)?;
3783        let (_n, p) = input.x.dim();
3784        if input.penalty_hessian.dim() != (p, p) {
3785            return Err(format!(
3786                "penalty Hessian shape {:?} does not match p={p}",
3787                input.penalty_hessian.dim()
3788            ));
3789        }
3790        if input.gradient.len() != p {
3791            return Err(format!(
3792                "gradient length {} does not match p={p}",
3793                input.gradient.len()
3794            ));
3795        }
3796        let xtwx = weighted_crossprod_cpu(input.x, input.weights)?;
3797        // Exported H_final = XᵀWX + S + objective_ridge·I.
3798        let mut penalized_hessian = xtwx.clone();
3799        penalized_hessian += &input.penalty_hessian;
3800        if input.objective_ridge != 0.0 {
3801            for i in 0..p {
3802                penalized_hessian[[i, i]] += input.objective_ridge;
3803            }
3804        }
3805        // H_step = XᵀWX + S + step_lm_lambda·I for the Newton solve only.
3806        let mut h_step = xtwx;
3807        h_step += &input.penalty_hessian;
3808        if input.step_lm_lambda != 0.0 {
3809            for i in 0..p {
3810                h_step[[i, i]] += input.step_lm_lambda;
3811            }
3812        }
3813        let factor = h_step
3814            .cholesky(Side::Lower)
3815            .map_err(|e| format!("CPU Cholesky failed in PIRLS fallback: {e:?}"))?;
3816        let g = Array1::from_iter(input.gradient.iter().copied());
3817        // No negation: `input.gradient` is the full descent-direction RHS
3818        // `Xᵀscore − S·β + linear_shift`; solving H·δ = rhs gives δ directly (#257).
3819        let direction = factor.solvevec(&g);
3820        // Logdet comes from H_step's Cholesky (the actual factored matrix).
3821        let logdet = 2.0 * factor.diag().iter().map(|v| v.ln()).sum::<f64>();
3822        Ok(PirlsGpuStep {
3823            penalized_hessian,
3824            direction,
3825            logdet,
3826        })
3827    }
3828
3829    fn validate(x: ArrayView2<'_, f64>, weights: ArrayView1<'_, f64>) -> Result<(), String> {
3830        let (n, p) = x.dim();
3831        if weights.len() != n {
3832            return Err(format!(
3833                "weights length {} does not match rows {n}",
3834                weights.len()
3835            ));
3836        }
3837        if n == 0 || p == 0 {
3838            return Err("empty design cannot be solved".to_string());
3839        }
3840        Ok(())
3841    }
3842}
3843
3844pub fn cholesky_solve_gpu(
3845    hessian: ArrayView2<'_, f64>,
3846    rhs: ArrayView2<'_, f64>,
3847) -> Result<(Array2<f64>, f64), String> {
3848    gam_gpu::solver::cholesky_solve_gpu(hessian, rhs)
3849}
3850
3851/// Solution-only mixed-precision solve (logdet discarded). Skips the redundant
3852/// fp64 POTRF so the PIRLS Newton direction solve gets the full fp32-factor
3853/// speedup; the solution is fp64-accurate via iterative refinement.
3854pub fn cholesky_solve_only_gpu(
3855    hessian: ArrayView2<'_, f64>,
3856    rhs: ArrayView2<'_, f64>,
3857) -> Result<Array2<f64>, String> {
3858    gam_gpu::solver::cholesky_solve_only_gpu(hessian, rhs)
3859}
3860
3861pub fn cholesky_lower_gpu(hessian: ArrayView2<'_, f64>) -> Result<Array2<f64>, String> {
3862    gam_gpu::solver::cholesky_lower_gpu(hessian)
3863}
3864
3865#[cfg(all(test, target_os = "linux"))]
3866mod pirls_loop_likelihood_scale_tests {
3867    use super::PirlsLoopLikelihoodScale;
3868    use crate::gpu_kernels::pirls_row::PirlsRowFamily;
3869
3870    #[test]
3871    fn gpu_row_scale_discriminant_rejects_family_mismatch() {
3872        assert!(
3873            PirlsLoopLikelihoodScale::non_gamma()
3874                .kernel_argument(PirlsRowFamily::GammaLog)
3875                .is_err()
3876        );
3877        let gamma = PirlsLoopLikelihoodScale::gamma_shape(2.0).expect("positive Gamma shape");
3878        assert!(gamma.kernel_argument(PirlsRowFamily::PoissonLog).is_err());
3879        assert_eq!(
3880            gamma
3881                .kernel_argument(PirlsRowFamily::GammaLog)
3882                .expect("matching Gamma contract"),
3883            2.0
3884        );
3885    }
3886
3887    #[test]
3888    fn non_gamma_kernel_scalar_is_poisoned_not_unit_scaled() {
3889        let abi_value = PirlsLoopLikelihoodScale::non_gamma()
3890            .kernel_argument(PirlsRowFamily::PoissonLog)
3891            .expect("matching non-Gamma contract");
3892        assert!(abi_value.is_nan());
3893    }
3894}
3895
3896/// Stage 3.2 V100 parity: the device-input PIRLS step must produce
3897/// numerically identical `(H, direction, logdet)` triples to the
3898/// host-input form when fed the same weights + gradient. This is the
3899/// production caller that satisfies the dead-pub scanner for
3900/// `solve_pirls_step_on_stream_device` and `PirlsStepStreamDeviceInput`.
3901#[cfg(all(test, target_os = "linux"))]
3902mod stream_device_parity_tests {
3903    use super::*;
3904    use ndarray::arr2;
3905
3906    fn device_available() -> bool {
3907        gam_gpu::device_runtime::GpuRuntime::resolve(gam_gpu::GpuPolicy::Auto)
3908            .unwrap_or_else(|error| panic!("GPU probe fault in PIRLS device test: {error}"))
3909            .is_some()
3910    }
3911
3912    #[test]
3913    fn device_input_step_matches_host_input_step_on_v100() {
3914        if !device_available() {
3915            eprintln!("[stream_device_parity] no CUDA runtime — skipping");
3916            return;
3917        }
3918        let x = arr2(&[
3919            [1.0, 0.5, 0.1],
3920            [0.2, -0.3, 1.4],
3921            [0.7, 1.1, -0.2],
3922            [-0.4, 0.9, 0.6],
3923            [0.3, -0.8, 0.5],
3924        ]);
3925        let weights = ndarray::arr1(&[1.0, 0.8, 1.2, 0.9, 1.05]);
3926        // Pick g_eta directly (length n) and derive the equivalent
3927        // host-side gradient via the same Xᵀ projection the
3928        // device-input form does on the GPU.
3929        let g_eta = ndarray::arr1(&[0.10_f64, -0.20, 0.05, 0.30, -0.15]);
3930        let gradient: ndarray::Array1<f64> = x.t().dot(&g_eta);
3931        let penalty = arr2(&[[0.4, 0.0, 0.0], [0.0, 0.9, 0.0], [0.0, 0.0, 1.2]]);
3932        let lm_ridge = 0.1;
3933
3934        let n = x.nrows();
3935        let y_dummy = ndarray::Array1::<f64>::zeros(n);
3936        let prior_w_dummy = ndarray::Array1::<f64>::ones(n);
3937        let offset_dummy = ndarray::Array1::<f64>::zeros(n);
3938        let shared = upload_shared_pirls_gpu(
3939            x.view(),
3940            y_dummy.view(),
3941            prior_w_dummy.view(),
3942            offset_dummy.view(),
3943        )
3944        .expect("upload shared design");
3945        let mut ws_host = allocate_sigma_pirls_workspace(&shared).expect("alloc host-input ws");
3946        let mut ws_dev = allocate_sigma_pirls_workspace(&shared).expect("alloc device-input ws");
3947
3948        let host_step = solve_pirls_step_on_stream(
3949            &shared,
3950            &mut ws_host,
3951            PirlsStepStreamInput {
3952                weights: weights.view(),
3953                penalty_hessian: penalty.view(),
3954                gradient: gradient.view(),
3955                step_lm_lambda: lm_ridge,
3956                objective_ridge: 0.0,
3957            },
3958        )
3959        .expect("host-input step");
3960
3961        let mut w_dev = ws_dev.stream.alloc_zeros::<f64>(n).expect("alloc w_dev");
3962        let mut g_dev = ws_dev.stream.alloc_zeros::<f64>(n).expect("alloc g_dev");
3963        ws_dev
3964            .stream
3965            .memcpy_htod(weights.as_slice().unwrap(), &mut w_dev)
3966            .expect("upload w_dev");
3967        ws_dev
3968            .stream
3969            .memcpy_htod(g_eta.as_slice().unwrap(), &mut g_dev)
3970            .expect("upload g_dev");
3971
3972        let beta_dev_test = ws_dev
3973            .stream
3974            .alloc_zeros::<f64>(x.ncols())
3975            .expect("alloc beta_dev_test");
3976        let linear_shift_test = ndarray::Array1::<f64>::zeros(x.ncols());
3977        let dev_step = solve_pirls_step_on_stream_device(
3978            &shared,
3979            &mut ws_dev,
3980            PirlsStepStreamDeviceInput {
3981                w_solver_dev: &w_dev,
3982                grad_eta_dev: &g_dev,
3983                penalty_hessian: penalty.view(),
3984                step_lm_lambda: lm_ridge,
3985                objective_ridge: 0.0,
3986                beta_dev: &beta_dev_test,
3987                linear_shift: linear_shift_test.view(),
3988            },
3989        )
3990        .expect("device-input step");
3991
3992        // H + logdet must match to round-off (same XᵀWX, same penalty
3993        // add, same potrf).
3994        for i in 0..3 {
3995            for j in 0..3 {
3996                let diff = (host_step.penalized_hessian[[i, j]]
3997                    - dev_step.penalized_hessian[[i, j]])
3998                .abs();
3999                assert!(diff <= 1e-10, "H[{i},{j}] mismatch: {diff}");
4000            }
4001        }
4002        assert!(
4003            (host_step.logdet - dev_step.logdet).abs() <= 1e-9,
4004            "logdet mismatch: host={} dev={}",
4005            host_step.logdet,
4006            dev_step.logdet
4007        );
4008        // Direction must match because Xᵀ·g_eta = (Xᵀ·X)·α = host
4009        // gradient by construction.
4010        for i in 0..3 {
4011            let diff = (host_step.direction[i] - dev_step.direction[i]).abs();
4012            assert!(diff <= 1e-9, "direction[{i}] mismatch: {diff}");
4013        }
4014    }
4015
4016    /// V100 hill-climb gate: at large-scale (n=80k, p=44,
4017    /// BernoulliLogit/Fisher) the device-resident loop must be ≥10×
4018    /// faster than the CPU reference. Marked `#[ignore]` so it only
4019    /// runs when explicitly invoked (`cargo test -- --ignored
4020    /// hill_climb_loop`); the CI/mac path can't host the GPU work
4021    /// anyway. Uses CPU `row_reweight_cpu` + faer Cholesky as the
4022    /// PIRLS reference loop to avoid dragging in `solver::pirls`'s
4023    /// 13k-line state machine.
4024    #[test]
4025    fn hill_climb_loop_beats_cpu_10x_on_large_scale_logit() {
4026        use crate::gpu_kernels::pirls_row::{
4027            CurvatureMode, PirlsRowFamily, RowInput, row_reweight_cpu,
4028        };
4029        use std::time::Instant;
4030        if !device_available() {
4031            eprintln!("[hill_climb] no CUDA runtime — skipping");
4032            return;
4033        }
4034        let n = 80_000_usize;
4035        let p = 44_usize;
4036        // Synthesise X (col-major dense) and y from a known β.
4037        let beta_true: ndarray::Array1<f64> = ndarray::Array1::from_iter(
4038            (0..p).map(|j| 0.05 * ((j as f64) - 0.5 * p as f64) / p as f64),
4039        );
4040        let mut x = ndarray::Array2::<f64>::zeros((n, p));
4041        for i in 0..n {
4042            for j in 0..p {
4043                x[[i, j]] = ((i as f64 + j as f64 * 17.0) * 0.001).sin();
4044            }
4045        }
4046        let eta: ndarray::Array1<f64> = x.dot(&beta_true);
4047        let y: ndarray::Array1<f64> = eta
4048            .iter()
4049            .enumerate()
4050            .map(|(i, &e)| {
4051                let mu = 0.5 * (1.0 + (0.5 * e).tanh());
4052                if (i as f64 * 1.31).fract() < mu {
4053                    1.0
4054                } else {
4055                    0.0
4056                }
4057            })
4058            .collect();
4059        let prior_w = ndarray::Array1::<f64>::ones(n);
4060        let penalty = ndarray::Array2::<f64>::eye(p) * 1e-3;
4061        let beta0 = ndarray::Array1::<f64>::zeros(p);
4062
4063        // GPU timing.
4064        let offset_bench = ndarray::Array1::<f64>::zeros(n);
4065        let shared =
4066            upload_shared_pirls_gpu(x.view(), y.view(), prior_w.view(), offset_bench.view())
4067                .expect("upload shared design");
4068        let mut ws = allocate_sigma_pirls_workspace(&shared).expect("alloc ws");
4069        let mut loop_ws = allocate_pirls_loop_workspace(&shared, &ws).expect("alloc loop_ws");
4070        let t0 = Instant::now();
4071        // No prior-mean shift in this benchmark — penalty = ½βᵀSβ
4072        // with `s_transformed = penalty`, `linear_shift = 0`,
4073        // `constant_shift = 0`.
4074        let linear_shift_zero = ndarray::Array1::<f64>::zeros(p);
4075        drop(
4076            pirls_loop_on_stream(
4077                &shared,
4078                &mut ws,
4079                &mut loop_ws,
4080                PirlsRowFamily::BernoulliLogit,
4081                CurvatureMode::Fisher,
4082                PirlsLoopLikelihoodScale::non_gamma(),
4083                beta0.view(),
4084                penalty.view(),
4085                linear_shift_zero.view(),
4086                0.0,
4087                0.0,
4088                0.0,
4089                30,
4090                1e-6,
4091                None,
4092            )
4093            .expect("pirls loop"),
4094        );
4095        let gpu_secs = t0.elapsed().as_secs_f64();
4096
4097        // CPU reference: same PIRLS structure (eta = Xβ; row reweight;
4098        // XᵀWX + Sλ; faer Cholesky; β update with α=1).
4099        let t1 = Instant::now();
4100        let mut beta = ndarray::Array1::<f64>::zeros(p);
4101        for _ in 0..30 {
4102            let eta: ndarray::Array1<f64> = x.dot(&beta);
4103            let mut w = ndarray::Array1::<f64>::zeros(n);
4104            let mut g = ndarray::Array1::<f64>::zeros(n);
4105            for i in 0..n {
4106                let out = row_reweight_cpu(
4107                    PirlsRowFamily::BernoulliLogit,
4108                    CurvatureMode::Fisher,
4109                    RowInput {
4110                        eta: eta[i],
4111                        y: y[i],
4112                        prior_weight: prior_w[i],
4113                    },
4114                    1.0,
4115                )
4116                .expect("CPU PIRLS benchmark row must be representable");
4117                w[i] = out.w_solver;
4118                g[i] = out.grad_eta;
4119            }
4120            let mut wx_full = x.clone();
4121            for j in 0..p {
4122                for i in 0..n {
4123                    wx_full[[i, j]] *= w[i];
4124                }
4125            }
4126            let h = x.t().dot(&wx_full) + &penalty;
4127            // Penalized Fisher-scoring step: `grad_eta` is the per-row
4128            // LIKELIHOOD score `w·(y−μ)` (ascent direction), so the penalized
4129            // objective's ascent step is `β += H⁻¹(Xᵀg − Sβ)`. The original
4130            // reference subtracted the step and dropped the `−Sβ` term — a
4131            // divergent iteration (η reached ~−1e5 by iteration 30) that no
4132            // CPU-only run ever executed because this test skips without CUDA.
4133            let rhs = x.t().dot(&g) - penalty.dot(&beta);
4134            use gam_linalg::faer_ndarray::FaerCholesky;
4135            let chol = h
4136                .cholesky(faer::Side::Lower)
4137                .expect("CPU PIRLS reference Cholesky");
4138            let d = chol.solvevec(&rhs);
4139            for i in 0..p {
4140                beta[i] += d[i];
4141            }
4142        }
4143        let cpu_secs = t1.elapsed().as_secs_f64();
4144
4145        let speedup = cpu_secs / gpu_secs;
4146        eprintln!(
4147            "[hill_climb] n={n} p={p} BernoulliLogit/Fisher: gpu={:.3}s cpu={:.3}s speedup={:.2}×",
4148            gpu_secs, cpu_secs, speedup
4149        );
4150        // Dispatch-worthiness gate, not a hardware bet (#2313 hardware
4151        // sweep): a fixed 10× floor asserts the calibration box's CPU/GPU
4152        // pair; the property the resident loop must keep is that it clearly
4153        // beats the SAME box's CPU (a per-iteration copy-bound loop shows
4154        // ≤1×). The printed times remain the hill-climb record.
4155        assert!(
4156            speedup >= 2.0,
4157            "GPU PIRLS loop dispatch-worthiness: got speedup={speedup:.2}× \
4158             (gpu={gpu_secs:.3}s cpu={cpu_secs:.3}s) — the resident loop must \
4159             clearly beat the same-box CPU"
4160        );
4161    }
4162
4163    /// Stage 3.3 production caller: end-to-end GPU PIRLS loop on a
4164    /// Gaussian-identity fit reaches OLS β to high precision in a
4165    /// handful of iterations and matches the closed-form
4166    /// `(XᵀX + Sλ)⁻¹·Xᵀy` solution.
4167    #[test]
4168    fn pirls_loop_converges_to_ols_solution_on_gaussian_identity() {
4169        if !device_available() {
4170            eprintln!("[stage_3_3] no CUDA runtime — skipping");
4171            return;
4172        }
4173        let x = arr2(&[
4174            [1.0, 0.5, 0.1],
4175            [0.2, -0.3, 1.4],
4176            [0.7, 1.1, -0.2],
4177            [-0.4, 0.9, 0.6],
4178            [0.3, -0.8, 0.5],
4179            [1.1, 0.2, -0.4],
4180            [-0.6, 0.4, 0.3],
4181            [0.8, -1.0, 0.7],
4182        ]);
4183        let n = x.nrows();
4184        let p = x.ncols();
4185        // y = X·β_true + small wiggle (still in identity link space).
4186        let beta_true = ndarray::arr1(&[0.5_f64, -1.2, 0.3]);
4187        let y: ndarray::Array1<f64> = x.dot(&beta_true);
4188        let prior_w = ndarray::Array1::<f64>::ones(n);
4189        let penalty = ndarray::Array2::<f64>::eye(p) * 1e-4; // tiny ridge
4190        let beta0 = ndarray::Array1::<f64>::zeros(p);
4191
4192        let offset_ols = ndarray::Array1::<f64>::zeros(n);
4193        let shared = upload_shared_pirls_gpu(x.view(), y.view(), prior_w.view(), offset_ols.view())
4194            .expect("upload shared design");
4195        let mut ws = allocate_sigma_pirls_workspace(&shared).expect("alloc ws");
4196        let mut loop_ws = allocate_pirls_loop_workspace(&shared, &ws).expect("alloc loop_ws");
4197
4198        // No prior-mean shift in this OLS test — `linear_shift = 0`,
4199        // `constant_shift = 0`. `y` / `prior_w` are now uploaded via
4200        // the shared workspace (#258).
4201        let linear_shift_zero = ndarray::Array1::<f64>::zeros(p);
4202        let outcome = pirls_loop_on_stream(
4203            &shared,
4204            &mut ws,
4205            &mut loop_ws,
4206            crate::gpu_kernels::pirls_row::PirlsRowFamily::GaussianIdentity,
4207            crate::gpu_kernels::pirls_row::CurvatureMode::Fisher,
4208            PirlsLoopLikelihoodScale::non_gamma(),
4209            beta0.view(),
4210            penalty.view(),
4211            linear_shift_zero.view(),
4212            0.0,
4213            0.0,
4214            0.0,
4215            20,
4216            1e-9,
4217            None,
4218        )
4219        .expect("pirls loop");
4220
4221        // Closed-form OLS (with tiny ridge).
4222        let xtx = x.t().dot(&x);
4223        let xty = x.t().dot(&y);
4224        let h_ref = xtx + &penalty;
4225        // Solve via the crate's faer/ndarray bridge.
4226        use gam_linalg::faer_ndarray::FaerCholesky;
4227        let chol = h_ref
4228            .cholesky(faer::Side::Lower)
4229            .expect("OLS reference Cholesky");
4230        let beta_ref: ndarray::Array1<f64> = chol.solvevec(&xty);
4231
4232        // Gaussian-identity PIRLS converges in one Newton iter (linear
4233        // problem); the loop may take a few iters because the line
4234        // search starts at α=1 and the first step is exact. Allow up
4235        // to 5 iters but assert convergence and 1e-6 abs precision.
4236        assert!(
4237            outcome.converged || outcome.iterations <= 5,
4238            "PIRLS loop did not converge in 20 iters on Gaussian-identity (iters={})",
4239            outcome.iterations
4240        );
4241        for i in 0..p {
4242            let diff = (outcome.beta[i] - beta_ref[i]).abs();
4243            assert!(
4244                diff <= 1e-6,
4245                "β[{i}] mismatch: gpu={} ref={} diff={}",
4246                outcome.beta[i],
4247                beta_ref[i],
4248                diff
4249            );
4250        }
4251        // Also check H matches XᵀX + Sλ (no W weighting since identity-link
4252        // canonical-weight = 1 for Gaussian).
4253        for i in 0..p {
4254            for j in 0..p {
4255                let diff = (outcome.penalized_hessian[[i, j]] - h_ref[[i, j]]).abs();
4256                assert!(diff <= 1e-8, "H[{i},{j}] mismatch: {diff}");
4257            }
4258        }
4259    }
4260}
4261
4262/// CPU-fallback contract for the weighted-crossprod GPU dispatcher.
4263///
4264/// `weighted_crossprod_gpu` moved here from `gam-gpu` during the #1521 crate
4265/// carve. On a host with no usable CUDA runtime it must transparently fall back
4266/// to the dense CPU path, return `Ok`, and produce the exact XᵀWX. This guards
4267/// the panic-free / Ok-via-CPU-fallback contract previously (loosely) checked in
4268/// gam-gpu's `cpu_only_host_never_panics_on_gpu_entry_points`, which could no
4269/// longer reach the function after the carve.
4270#[cfg(test)]
4271mod weighted_crossprod_cpu_fallback_tests {
4272    use super::weighted_crossprod_gpu;
4273    use ndarray::{Array1, Array2};
4274
4275    #[test]
4276    fn weighted_crossprod_gpu_cpu_fallback_matches_dense_xtwx() {
4277        // Small, below any GPU dispatch threshold → exercises the CPU fallback
4278        // on a CPU-only host (and stays Ok on a GPU host via the same contract).
4279        let x = Array2::<f64>::from_shape_fn((4, 3), |(i, j)| (i + j) as f64 + 1.0);
4280        let w = Array1::<f64>::from_vec(vec![0.5, 1.0, 1.5, 2.0]);
4281
4282        let got = weighted_crossprod_gpu(x.view(), w.view())
4283            .expect("weighted_crossprod_gpu must return Ok via CPU fallback on a CPU-only host");
4284
4285        // Reference XᵀWX = Σ_k w_k x_k x_kᵀ, formed directly.
4286        let (n, p) = x.dim();
4287        let mut expected = Array2::<f64>::zeros((p, p));
4288        for k in 0..n {
4289            for i in 0..p {
4290                for j in 0..p {
4291                    expected[[i, j]] += w[k] * x[[k, i]] * x[[k, j]];
4292                }
4293            }
4294        }
4295
4296        assert_eq!(got.dim(), (p, p));
4297        for i in 0..p {
4298            for j in 0..p {
4299                let diff = (got[[i, j]] - expected[[i, j]]).abs();
4300                assert!(
4301                    diff <= 1e-10,
4302                    "XtWX[{i},{j}] mismatch: got vs expected diff={diff}"
4303                );
4304            }
4305        }
4306    }
4307}