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