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    /// In-place Newton step: rhs = Xᵀ·score − S·β + linear_shift (#257, #260).
865    ///
866    /// Solves H·δ = rhs (H = XᵀWX + S + step_lm_lambda·I). On return
867    /// `ws.rhs_dev` holds the Newton descent direction δ (not negated).
868    /// The loop copies `ws.rhs_dev` to `direction_dev` via `memcpy_dtod`.
869    ///
870    /// On return `ws.h_dev` holds the Cholesky factor; rebuild with
871    /// `rebuild_h_final` to get the exported penalised Hessian.
872    ///
873    /// Returns `logdet = log|H|` computed device-side.
874    pub(super) fn solve_step_on_stream_device_inplace(
875        shared: &PirlsGpuSharedData,
876        ws: &mut SigmaPirlsGpuWorkspace,
877        input: PirlsStepStreamDeviceInput<'_, '_>,
878    ) -> Result<f64, String> {
879        let n = shared.n;
880        let p = shared.p;
881        if ws.n != n || ws.p != p {
882            return Err(format!(
883                "workspace shape ({}, {}) does not match shared design ({n}, {p})",
884                ws.n, ws.p
885            ));
886        }
887        if input.w_solver_dev.len() != n {
888            return Err(format!(
889                "w_solver_dev length {} does not match n={n}",
890                input.w_solver_dev.len()
891            ));
892        }
893        if input.grad_eta_dev.len() != n {
894            return Err(format!(
895                "grad_eta_dev length {} does not match n={n}",
896                input.grad_eta_dev.len()
897            ));
898        }
899        if input.penalty_hessian.dim() != (p, p) {
900            return Err(format!(
901                "penalty Hessian shape {:?} does not match p={p}",
902                input.penalty_hessian.dim()
903            ));
904        }
905
906        if input.linear_shift.len() != p {
907            return Err(format!(
908                "linear_shift length {} does not match p={p}",
909                input.linear_shift.len()
910            ));
911        }
912        if input.beta_dev.len() != p {
913            return Err(format!(
914                "beta_dev length {} does not match p={p}",
915                input.beta_dev.len()
916            ));
917        }
918        let n_i = to_i32(n)?;
919        let p_i = to_i32(p)?;
920
921        // Step 1: A = X_origᵀ diag(w_solver) X_orig → ws.xtwx_dev.
922        //         score_p = X_origᵀ grad_eta → ws.rhs_dev.
923        if let Some(ref mut wx_dev_ib) = ws.wx_dev {
924            // Large-p path: ddgmm then dgemm, then gemv.
925            left_scale_rows_borrowed(
926                &ws.blas,
927                &ws.stream,
928                n,
929                p,
930                &shared.x_original_dev,
931                input.w_solver_dev,
932                wx_dev_ib,
933            )?;
934            let cfg_xtx = GemmConfig::<f64> {
935                transa: cublasOperation_t::CUBLAS_OP_T,
936                transb: cublasOperation_t::CUBLAS_OP_N,
937                m: p_i,
938                n: p_i,
939                k: n_i,
940                alpha: 1.0,
941                lda: n_i,
942                ldb: n_i,
943                beta: 0.0,
944                ldc: p_i,
945            };
946            // SAFETY: x_original_dev and wx_dev_ib n*p col-major; xtwx_dev p*p; ws.stream.
947            unsafe {
948                ws.blas
949                    .gemm(cfg_xtx, &shared.x_original_dev, wx_dev_ib, &mut ws.xtwx_dev)
950            }
951            .map_err(|e| format!("dgemm XtWX inplace (large-p): {e}"))?;
952            let cfg_xts = GemvConfig::<f64> {
953                trans: cublasOperation_t::CUBLAS_OP_T,
954                m: n_i,
955                n: p_i,
956                alpha: 1.0,
957                lda: n_i,
958                incx: 1,
959                beta: 0.0,
960                incy: 1,
961            };
962            // SAFETY: x_original_dev n*p col-major; grad_eta_dev length n; rhs_dev length p.
963            unsafe {
964                ws.blas.gemv(
965                    cfg_xts,
966                    &shared.x_original_dev,
967                    input.grad_eta_dev,
968                    &mut ws.rhs_dev,
969                )
970            }
971            .map_err(|e| format!("dgemv Xᵀ·score inplace (large-p): {e}"))?;
972        } else {
973            // Fused path: row-sweep kernels, no n*p WX buffer.
974            launch_xtwx_lower(
975                &ws.stream,
976                &shared.ctx,
977                n,
978                p,
979                &shared.x_original_dev,
980                input.w_solver_dev,
981                &mut ws.xtwx_dev,
982            )?;
983            launch_symmetrize_lower(&ws.stream, &shared.ctx, p, &mut ws.xtwx_dev)?;
984            launch_xtscore(
985                &ws.stream,
986                &shared.ctx,
987                n,
988                p,
989                &shared.x_original_dev,
990                input.grad_eta_dev,
991                &mut ws.rhs_dev,
992            )?;
993        }
994
995        // Step 2: H_xtx = Qsᵀ A Qs  (two p×p gemms).
996        //   tmp = A · Qs → ws.qs_tmp_dev.
997        {
998            let cfg_aq = GemmConfig::<f64> {
999                transa: cublasOperation_t::CUBLAS_OP_N,
1000                transb: cublasOperation_t::CUBLAS_OP_N,
1001                m: p_i,
1002                n: p_i,
1003                k: p_i,
1004                alpha: 1.0,
1005                lda: p_i,
1006                ldb: p_i,
1007                beta: 0.0,
1008                ldc: p_i,
1009            };
1010            // SAFETY: xtwx_dev and qs_dev p*p col-major; qs_tmp_dev p*p output.
1011            unsafe {
1012                ws.blas
1013                    .gemm(cfg_aq, &ws.xtwx_dev, &ws.qs_dev, &mut ws.qs_tmp_dev)
1014            }
1015            .map_err(|e| format!("dgemm A·Qs inplace: {e}"))?;
1016        }
1017        //   H_xtx = Qsᵀ · tmp → ws.h_dev.
1018        {
1019            let cfg_qt = GemmConfig::<f64> {
1020                transa: cublasOperation_t::CUBLAS_OP_T,
1021                transb: cublasOperation_t::CUBLAS_OP_N,
1022                m: p_i,
1023                n: p_i,
1024                k: p_i,
1025                alpha: 1.0,
1026                lda: p_i,
1027                ldb: p_i,
1028                beta: 0.0,
1029                ldc: p_i,
1030            };
1031            // SAFETY: qs_dev p*p (transposed); qs_tmp_dev p*p; h_dev p*p output.
1032            unsafe {
1033                ws.blas
1034                    .gemm(cfg_qt, &ws.qs_dev, &ws.qs_tmp_dev, &mut ws.h_dev)
1035            }
1036            .map_err(|e| format!("dgemm Qsᵀ·A·Qs inplace: {e}"))?;
1037        }
1038        // H_step = H_xtx + (S + step_lm_lambda·I).
1039        let penalty_step = penalty_with_ridge(input.penalty_hessian, input.step_lm_lambda);
1040        let penalty_step_col = to_col_major(&penalty_step);
1041        ws.stream
1042            .memcpy_htod(penalty_step_col.as_ref(), &mut ws.penalty_dev)
1043            .map_err(|e| format!("upload penalty inplace: {e}"))?;
1044        geam_add_inplace(&ws.blas, &ws.stream, p, &mut ws.h_dev, &ws.penalty_dev)?;
1045
1046        // Step 3: rhs = Qsᵀ score_p − S·β + linear_shift  (#257, #260).
1047        // First project score_p through Qsᵀ on device (p×p gemv):
1048        //   beta_orig_dev = Qsᵀ · rhs_dev,  then swap back.
1049        {
1050            let cfg_qts = GemvConfig::<f64> {
1051                trans: cublasOperation_t::CUBLAS_OP_T,
1052                m: p_i,
1053                n: p_i,
1054                alpha: 1.0,
1055                lda: p_i,
1056                incx: 1,
1057                beta: 0.0,
1058                incy: 1,
1059            };
1060            // SAFETY: qs_dev p*p (transposed); rhs_dev length p; beta_orig_dev length p.
1061            unsafe {
1062                ws.blas
1063                    .gemv(cfg_qts, &ws.qs_dev, &ws.rhs_dev, &mut ws.beta_orig_dev)
1064            }
1065            .map_err(|e| format!("dgemv Qsᵀ·score inplace: {e}"))?;
1066            ws.stream
1067                .memcpy_dtod(&ws.beta_orig_dev, &mut ws.rhs_dev)
1068                .map_err(|e| format!("d2d Qsᵀ·score→rhs inplace: {e}"))?;
1069        }
1070        // Keep the correction in coefficient space on the device. The prior
1071        // implementation downloaded both rhs and beta, performed Sβ on the
1072        // CPU, and uploaded rhs again on every iteration. Those small transfers
1073        // still drain the entire CUDA stream and dominated the n=80k, p=44
1074        // device-resident loop (#2430).
1075        ws.stream
1076            .memcpy_htod(
1077                input
1078                    .linear_shift
1079                    .as_slice()
1080                    .ok_or("linear_shift must be contiguous")?,
1081                &mut ws.dir_orig_dev,
1082            )
1083            .map_err(|e| format!("upload linear shift inplace: {e}"))?;
1084        let loop_module = PIRLS_LOOP_CACHE
1085            .get_or_compile(&shared.ctx, "pirls_loop", PIRLS_LOOP_PTX_SOURCE)
1086            .map_err(|e| format!("load rhs-correction module: {e}"))?;
1087        let correction_func = loop_module
1088            .load_function("correct_newton_rhs")
1089            .map_err(|e| format!("load correct_newton_rhs: {e}"))?;
1090        let cfg = LaunchConfig {
1091            grid_dim: ((p as u32).div_ceil(256).max(1), 1, 1),
1092            block_dim: (256, 1, 1),
1093            shared_mem_bytes: 0,
1094        };
1095        let mut builder = ws.stream.launch_builder(&correction_func);
1096        builder.arg(&mut ws.rhs_dev);
1097        builder.arg(&ws.penalty_dev);
1098        builder.arg(input.beta_dev);
1099        builder.arg(&ws.dir_orig_dev);
1100        builder.arg(&input.step_lm_lambda);
1101        builder.arg(&p_i);
1102        builder.arg(&mut ws.beta_orig_dev);
1103        // SAFETY: correct_newton_rhs receives p-sized rhs/beta/shift vectors
1104        // and a column-major p×p penalty matrix; the launch covers p threads
1105        // and preserves `(S + lm I)β` in coefficient scratch for the selector.
1106        unsafe { builder.launch(cfg) }
1107            .map_err(|e| format!("correct Newton rhs on device: {e}"))?;
1108
1109        // Step 4: Cholesky factor + solve in-place.
1110        potrf_in_place_reuse(
1111            &ws.solver,
1112            &ws.stream,
1113            p,
1114            ws.potrf_lwork,
1115            &mut ws.h_dev,
1116            &mut ws.potrf_work_dev,
1117            &mut ws.potrf_info_dev,
1118        )?;
1119        potrs_in_place_reuse(
1120            &ws.solver,
1121            &ws.stream,
1122            p,
1123            1,
1124            &ws.h_dev,
1125            &mut ws.rhs_dev,
1126            &mut ws.potrs_info_dev,
1127        )?;
1128        let logdet = cholesky_logdet_device(&ws.stream, &shared.ctx, p, &ws.h_dev)?;
1129        check_deferred_potrf_info(&ws.stream, &ws.potrf_info_dev)?;
1130        check_deferred_potrs_info(&ws.stream, &ws.potrs_info_dev)?;
1131
1132        // ws.rhs_dev = δ = H⁻¹·(Qsᵀ score_p − Sβ + linear_shift) — descent direction.
1133        // No negation: the corrected RHS directly gives the descent direction (#257).
1134        Ok(logdet)
1135    }
1136
1137    /// Rebuild the penalised Hessian `H = XᵀW_hessianX + S + objective_ridge·I`
1138    /// on device using the accepted `w_hessian` weights and download it once.
1139    /// Called once after PIRLS convergence so the exported Hessian reflects
1140    /// the accepted eta, not a stale mid-loop snapshot.
1141    ///
1142    /// Uses `ws.wx_dev`, `ws.xtwx_dev`, `ws.h_dev`, `ws.penalty_dev` as
1143    /// scratch — all are fair game post-loop.
1144    pub(super) fn rebuild_h_final(
1145        shared: &PirlsGpuSharedData,
1146        ws: &mut SigmaPirlsGpuWorkspace,
1147        w_hessian_dev: &CudaSlice<f64>,
1148        penalty_hessian: ArrayView2<'_, f64>,
1149        objective_ridge: f64,
1150    ) -> Result<Array2<f64>, String> {
1151        let n = shared.n;
1152        let p = shared.p;
1153
1154        // XtWX via fused path (no n*p WX temp) or fallback ddgmm + dgemm.
1155        if let Some(ref mut wx_dev_rh) = ws.wx_dev {
1156            // Large-p fallback: WX = diag(w_hessian) · X.
1157            left_scale_rows_borrowed(
1158                &ws.blas,
1159                &ws.stream,
1160                n,
1161                p,
1162                &shared.x_original_dev,
1163                w_hessian_dev,
1164                wx_dev_rh,
1165            )?;
1166            let n_i = to_i32(n)?;
1167            let p_i = to_i32(p)?;
1168            let gemm_cfg = GemmConfig::<f64> {
1169                transa: cublasOperation_t::CUBLAS_OP_T,
1170                transb: cublasOperation_t::CUBLAS_OP_N,
1171                m: p_i,
1172                n: p_i,
1173                k: n_i,
1174                alpha: 1.0,
1175                lda: n_i,
1176                ldb: n_i,
1177                beta: 0.0,
1178                ldc: p_i,
1179            };
1180            // SAFETY: validated dims; shared.x_original_dev and wx_dev_rh n*p
1181            // col-major; ws.xtwx_dev is p*p; all on ws.stream.
1182            unsafe {
1183                ws.blas.gemm(
1184                    gemm_cfg,
1185                    &shared.x_original_dev,
1186                    wx_dev_rh,
1187                    &mut ws.xtwx_dev,
1188                )
1189            }
1190            .map_err(|e| format!("cublas dgemm XtWX (final H rebuild): {e}"))?;
1191        } else {
1192            // Fused path: xtwx_lower + symmetrize, no n*p temp.
1193            launch_xtwx_lower(
1194                &ws.stream,
1195                &shared.ctx,
1196                n,
1197                p,
1198                &shared.x_original_dev,
1199                w_hessian_dev,
1200                &mut ws.xtwx_dev,
1201            )?;
1202            launch_symmetrize_lower(&ws.stream, &shared.ctx, p, &mut ws.xtwx_dev)?;
1203        }
1204
1205        // H_final = Qsᵀ (XtWX) Qs + S + objective_ridge·I.
1206        let p_i = to_i32(p)?;
1207        // tmp = XtWX · Qs → ws.qs_tmp_dev.
1208        {
1209            let cfg_aq = GemmConfig::<f64> {
1210                transa: cublasOperation_t::CUBLAS_OP_N,
1211                transb: cublasOperation_t::CUBLAS_OP_N,
1212                m: p_i,
1213                n: p_i,
1214                k: p_i,
1215                alpha: 1.0,
1216                lda: p_i,
1217                ldb: p_i,
1218                beta: 0.0,
1219                ldc: p_i,
1220            };
1221            // SAFETY: xtwx_dev and qs_dev p*p col-major; qs_tmp_dev p*p output.
1222            unsafe {
1223                ws.blas
1224                    .gemm(cfg_aq, &ws.xtwx_dev, &ws.qs_dev, &mut ws.qs_tmp_dev)
1225            }
1226            .map_err(|e| format!("dgemm A·Qs (final H rebuild): {e}"))?;
1227        }
1228        // H_xtx = Qsᵀ · tmp → ws.h_dev.
1229        {
1230            let cfg_qt = GemmConfig::<f64> {
1231                transa: cublasOperation_t::CUBLAS_OP_T,
1232                transb: cublasOperation_t::CUBLAS_OP_N,
1233                m: p_i,
1234                n: p_i,
1235                k: p_i,
1236                alpha: 1.0,
1237                lda: p_i,
1238                ldb: p_i,
1239                beta: 0.0,
1240                ldc: p_i,
1241            };
1242            // SAFETY: qs_dev p*p (transposed); qs_tmp_dev p*p; h_dev p*p output.
1243            unsafe {
1244                ws.blas
1245                    .gemm(cfg_qt, &ws.qs_dev, &ws.qs_tmp_dev, &mut ws.h_dev)
1246            }
1247            .map_err(|e| format!("dgemm Qsᵀ·A·Qs (final H rebuild): {e}"))?;
1248        }
1249        let penalty = penalty_with_ridge(penalty_hessian, objective_ridge);
1250        let penalty_col = to_col_major(&penalty);
1251        ws.stream
1252            .memcpy_htod(penalty_col.as_ref(), &mut ws.penalty_dev)
1253            .map_err(|e| format!("upload penalty (final H rebuild): {e}"))?;
1254        geam_add_inplace(&ws.blas, &ws.stream, p, &mut ws.h_dev, &ws.penalty_dev)?;
1255
1256        // One download — the only H transfer in the entire PIRLS loop.
1257        let h_col = ws
1258            .stream
1259            .clone_dtoh(&ws.h_dev)
1260            .map_err(|e| format!("download H_final: {e}"))?;
1261        from_col_major(&h_col, p, p).ok_or_else(|| "H_final layout conversion failed".to_string())
1262    }
1263
1264    pub(super) fn weighted_crossprod(
1265        x: ArrayView2<'_, f64>,
1266        weights: ArrayView1<'_, f64>,
1267    ) -> Result<Array2<f64>, String> {
1268        let (_, stream) = context_and_stream()?;
1269        let (n, p) = validate_design(x, weights)?;
1270        let blas = CudaBlas::new(stream.clone()).map_err(|e| format!("cublas init: {e}"))?;
1271        let x_col = to_col_major(&x);
1272        let x_dev = pinned_htod(&stream, &x_col)?;
1273        let mut w_dev = pinned_htod(
1274            &stream,
1275            weights.as_slice().ok_or("weights must be contiguous")?,
1276        )?;
1277        let mut wx_dev = stream
1278            .alloc_zeros::<f64>(n.checked_mul(p).ok_or("X size overflow")?)
1279            .map_err(|e| format!("cuda alloc WX: {e}"))?;
1280        left_scale_rows(&blas, &stream, n, p, &x_dev, &mut w_dev, &mut wx_dev)?;
1281        let mut h_dev = stream
1282            .alloc_zeros::<f64>(p.checked_mul(p).ok_or("H size overflow")?)
1283            .map_err(|e| format!("cuda alloc H: {e}"))?;
1284        let n_i = to_i32(n)?;
1285        let p_i = to_i32(p)?;
1286        let cfg = GemmConfig::<f64> {
1287            transa: cublasOperation_t::CUBLAS_OP_T,
1288            transb: cublasOperation_t::CUBLAS_OP_N,
1289            m: p_i,
1290            n: p_i,
1291            k: n_i,
1292            alpha: 1.0,
1293            lda: n_i,
1294            ldb: n_i,
1295            beta: 0.0,
1296            ldc: p_i,
1297        };
1298        // SAFETY: cuBLAS dgemm with validated i32 dimensions; x_dev/wx_dev are n*p f64 device
1299        // buffers and h_dev is the p*p output, all allocated above with matching sizes.
1300        unsafe { blas.gemm(cfg, &x_dev, &wx_dev, &mut h_dev) }
1301            .map_err(|e| format!("cublas dgemm XtWX: {e}"))?;
1302        let h_col = stream
1303            .clone_dtoh(&h_dev)
1304            .map_err(|e| format!("download H: {e}"))?;
1305        from_col_major(&h_col, p, p).ok_or_else(|| "H layout conversion failed".to_string())
1306    }
1307
1308    pub(super) fn solve_step(input: PirlsGpuInput<'_>) -> Result<PirlsGpuStep, String> {
1309        // One-shot path for the legacy single-step API: validate, build a
1310        // one-shot shared+workspace, run a single step, drop. This routes
1311        // through `solve_step_on_stream` so there is exactly one math path
1312        // for both the batch-mode cubature executor and the single-step
1313        // test/bench surface.
1314        let (_, p) = validate_design(input.x, input.weights)?;
1315        if input.penalty_hessian.dim() != (p, p) {
1316            return Err(format!(
1317                "penalty Hessian shape {:?} does not match p={p}",
1318                input.penalty_hessian.dim()
1319            ));
1320        }
1321        if input.gradient.len() != p {
1322            return Err(format!(
1323                "gradient length {} does not match p={p}",
1324                input.gradient.len()
1325            ));
1326        }
1327        // The legacy single-step API has no GLM data — `solve_step_on_stream`
1328        // (which this dispatches to) only reads `shared.x_original_dev`.
1329        // The shared upload requires y/prior_w/offset for the loop paths, so
1330        // pass zero placeholders sized to the design's row count; they are
1331        // never read by the one-shot Newton step path.
1332        let n_rows = input.x.nrows();
1333        let zero_n = ndarray::Array1::<f64>::zeros(n_rows);
1334        let shared =
1335            PirlsGpuSharedData::upload_impl(input.x, zero_n.view(), zero_n.view(), zero_n.view())?;
1336        let mut ws = SigmaPirlsGpuWorkspace::allocate_impl(&shared)?;
1337        solve_step_on_stream(
1338            &shared,
1339            &mut ws,
1340            PirlsStepStreamInput {
1341                weights: input.weights,
1342                penalty_hessian: input.penalty_hessian,
1343                gradient: input.gradient,
1344                step_lm_lambda: input.step_lm_lambda,
1345                objective_ridge: input.objective_ridge,
1346            },
1347        )
1348    }
1349
1350    fn validate_design(
1351        x: ArrayView2<'_, f64>,
1352        weights: ArrayView1<'_, f64>,
1353    ) -> Result<(usize, usize), String> {
1354        let (n, p) = x.dim();
1355        if weights.len() != n {
1356            return Err(format!(
1357                "weights length {} does not match rows {n}",
1358                weights.len()
1359            ));
1360        }
1361        if n == 0 || p == 0 {
1362            return Err("empty design cannot be solved on CUDA".to_string());
1363        }
1364        Ok((n, p))
1365    }
1366
1367    fn left_scale_rows(
1368        blas: &CudaBlas,
1369        stream: &std::sync::Arc<cudarc::driver::CudaStream>,
1370        n: usize,
1371        p: usize,
1372        x_dev: &CudaSlice<f64>,
1373        w_dev: &mut CudaSlice<f64>,
1374        wx_dev: &mut CudaSlice<f64>,
1375    ) -> Result<(), String> {
1376        let n_i = to_i32(n)?;
1377        let p_i = to_i32(p)?;
1378        let handle = *blas.handle();
1379        let (x_ptr, _x_record) = x_dev.device_ptr(stream);
1380        let (w_ptr, _w_record) = w_dev.device_ptr(stream);
1381        let (wx_ptr, _wx_record) = wx_dev.device_ptr_mut(stream);
1382        // SAFETY: FFI call into cuBLAS; pointers come from live CudaSlice device buffers sized
1383        // n*p (x, wx) and n (w), leading dims match column-major layout, handle is valid.
1384        let status = unsafe {
1385            cublasDdgmm(
1386                handle,
1387                cublasSideMode_t::CUBLAS_SIDE_LEFT,
1388                n_i,
1389                p_i,
1390                x_ptr as *const f64,
1391                n_i,
1392                w_ptr as *const f64,
1393                1,
1394                wx_ptr as *mut f64,
1395                n_i,
1396            )
1397        };
1398        if status == cublasStatus_t::CUBLAS_STATUS_SUCCESS {
1399            Ok(())
1400        } else {
1401            Err(format!("cublasDdgmm failed with {status:?}"))
1402        }
1403    }
1404
1405    /// Borrowed-input variant of [`left_scale_rows`] used by the Stage 3.2
1406    /// device-input PIRLS step. Reads weights through `&CudaSlice` so the
1407    /// caller can keep ownership of the row-reweight buffer across the
1408    /// PIRLS iteration without an extra device-side copy.
1409    fn left_scale_rows_borrowed(
1410        blas: &CudaBlas,
1411        stream: &std::sync::Arc<cudarc::driver::CudaStream>,
1412        n: usize,
1413        p: usize,
1414        x_dev: &CudaSlice<f64>,
1415        w_dev: &CudaSlice<f64>,
1416        wx_dev: &mut CudaSlice<f64>,
1417    ) -> Result<(), String> {
1418        let n_i = to_i32(n)?;
1419        let p_i = to_i32(p)?;
1420        let handle = *blas.handle();
1421        let (x_ptr, _x_record) = x_dev.device_ptr(stream);
1422        let (w_ptr, _w_record) = w_dev.device_ptr(stream);
1423        let (wx_ptr, _wx_record) = wx_dev.device_ptr_mut(stream);
1424        // SAFETY: FFI call into cuBLAS; pointers come from live CudaSlice
1425        // device buffers; x is n*p col-major (lda = n), w is length n
1426        // (stride 1), wx is n*p output (lda = n). Caller-owned w buffer
1427        // is borrowed read-only here, matching cublasDdgmm's contract.
1428        let status = unsafe {
1429            cublasDdgmm(
1430                handle,
1431                cublasSideMode_t::CUBLAS_SIDE_LEFT,
1432                n_i,
1433                p_i,
1434                x_ptr as *const f64,
1435                n_i,
1436                w_ptr as *const f64,
1437                1,
1438                wx_ptr as *mut f64,
1439                n_i,
1440            )
1441        };
1442        if status == cublasStatus_t::CUBLAS_STATUS_SUCCESS {
1443            Ok(())
1444        } else {
1445            Err(format!("cublasDdgmm (borrowed) failed with {status:?}"))
1446        }
1447    }
1448
1449    // In-place `a := a + b` for two `p*p` column-major device buffers via
1450    // cublasDgeam. The C API explicitly permits `C = A` (output aliasing the
1451    // first input), but Rust's borrow checker cannot prove that — every
1452    // caller historically passed `&ws.h_dev, &ws.penalty_dev, &mut ws.h_dev`
1453    // and ran into E0502. Forcing the in-place semantics into the wrapper
1454    // signature makes the contract explicit and removes the aliasing-borrow
1455    // class of errors at the call sites.
1456    fn geam_add_inplace(
1457        blas: &CudaBlas,
1458        stream: &std::sync::Arc<cudarc::driver::CudaStream>,
1459        p: usize,
1460        a: &mut CudaSlice<f64>,
1461        b: &CudaSlice<f64>,
1462    ) -> Result<(), String> {
1463        let p_i = to_i32(p)?;
1464        let alpha = 1.0_f64;
1465        let beta = 1.0_f64;
1466        let handle = *blas.handle();
1467        let (b_ptr, _b_record) = b.device_ptr(stream);
1468        let (a_ptr, _a_record) = a.device_ptr_mut(stream);
1469        // cublasDgeam with C == A is allowed and computes `A := alpha*A + beta*B`.
1470        let out_ptr = a_ptr;
1471        // SAFETY: FFI call into cuBLAS geam; a, b, out are live p*p device buffers in column-major
1472        // with leading dim p_i, scalars live on host stack, handle is valid.
1473        let status = unsafe {
1474            cublasDgeam(
1475                handle,
1476                cublasOperation_t::CUBLAS_OP_N,
1477                cublasOperation_t::CUBLAS_OP_N,
1478                p_i,
1479                p_i,
1480                &alpha,
1481                a_ptr as *const f64,
1482                p_i,
1483                &beta,
1484                b_ptr as *const f64,
1485                p_i,
1486                out_ptr as *mut f64,
1487                p_i,
1488            )
1489        };
1490        if status == cublasStatus_t::CUBLAS_STATUS_SUCCESS {
1491            Ok(())
1492        } else {
1493            Err(format!("cublasDgeam failed with {status:?}"))
1494        }
1495    }
1496
1497    /// Launch the `xtwx_lower` kernel: one thread per lower-tri pair `(j,k)`,
1498    /// iterates over all `n` rows and writes `A[j + k*p]` (col-major lower
1499    /// triangle of `XᵀWX`). Call `launch_symmetrize_lower` afterwards.
1500    fn launch_xtwx_lower(
1501        stream: &std::sync::Arc<cudarc::driver::CudaStream>,
1502        ctx: &std::sync::Arc<cudarc::driver::CudaContext>,
1503        n: usize,
1504        p: usize,
1505        x_dev: &CudaSlice<f64>,
1506        w_dev: &CudaSlice<f64>,
1507        a_dev: &mut CudaSlice<f64>,
1508    ) -> Result<(), String> {
1509        let module = FUSED_XTWX_CACHE
1510            .get_or_compile(ctx, "fused_xtwx", FUSED_XTWX_PTX_SOURCE)
1511            .map_err(|e| format!("fused_xtwx module: {e}"))?;
1512        let func = module
1513            .load_function("xtwx_lower")
1514            .map_err(|e| format!("load xtwx_lower: {e}"))?;
1515        let n_i = to_i32(n)?;
1516        let p_i = to_i32(p)?;
1517        let num_pairs = p * (p + 1) / 2;
1518        let num_pairs_u32 = u32::try_from(num_pairs)
1519            .map_err(|_| format!("xtwx_lower: num_pairs {num_pairs} > u32"))?;
1520        const BLOCK: u32 = 256;
1521        let grid = num_pairs_u32.div_ceil(BLOCK).max(1);
1522        let cfg = cudarc::driver::LaunchConfig {
1523            grid_dim: (grid, 1, 1),
1524            block_dim: (BLOCK, 1, 1),
1525            shared_mem_bytes: 0,
1526        };
1527        let mut builder = stream.launch_builder(&func);
1528        builder.arg(x_dev);
1529        builder.arg(w_dev);
1530        builder.arg(a_dev);
1531        builder.arg(&n_i);
1532        builder.arg(&p_i);
1533        // SAFETY: x_dev is n*p col-major f64; w_dev is length n; a_dev is p*p;
1534        // num_pairs threads each write one lower-tri entry A[j + k*p].
1535        unsafe { builder.launch(cfg) }.map_err(|e| format!("xtwx_lower launch: {e}"))?;
1536        Ok(())
1537    }
1538
1539    /// Launch the `xtscore` kernel: one thread per output index `j`,
1540    /// iterates over `n` rows and writes `s[j] = sum_i score[i]*X[i,j]`.
1541    fn launch_xtscore(
1542        stream: &std::sync::Arc<cudarc::driver::CudaStream>,
1543        ctx: &std::sync::Arc<cudarc::driver::CudaContext>,
1544        n: usize,
1545        p: usize,
1546        x_dev: &CudaSlice<f64>,
1547        score_dev: &CudaSlice<f64>,
1548        s_dev: &mut CudaSlice<f64>,
1549    ) -> Result<(), String> {
1550        let module = FUSED_XTWX_CACHE
1551            .get_or_compile(ctx, "fused_xtwx", FUSED_XTWX_PTX_SOURCE)
1552            .map_err(|e| format!("fused_xtwx module (xtscore): {e}"))?;
1553        let func = module
1554            .load_function("xtscore")
1555            .map_err(|e| format!("load xtscore: {e}"))?;
1556        let n_i = to_i32(n)?;
1557        let p_i = to_i32(p)?;
1558        let p_u32 = u32::try_from(p).map_err(|_| format!("xtscore: p {p} > u32"))?;
1559        const BLOCK: u32 = 256;
1560        let grid = p_u32.div_ceil(BLOCK).max(1);
1561        let cfg = cudarc::driver::LaunchConfig {
1562            grid_dim: (grid, 1, 1),
1563            block_dim: (BLOCK, 1, 1),
1564            shared_mem_bytes: 0,
1565        };
1566        let mut builder = stream.launch_builder(&func);
1567        builder.arg(x_dev);
1568        builder.arg(score_dev);
1569        builder.arg(s_dev);
1570        builder.arg(&n_i);
1571        builder.arg(&p_i);
1572        // SAFETY: x_dev is n*p col-major f64; score_dev is length n; s_dev is length p;
1573        // p threads each write one output entry s[j].
1574        unsafe { builder.launch(cfg) }.map_err(|e| format!("xtscore launch: {e}"))?;
1575        Ok(())
1576    }
1577
1578    /// Launch the `symmetrize_lower` kernel: one thread per strict lower-tri
1579    /// pair `(j,k)` with `j > k`; copies `A[k + j*p] = A[j + k*p]` to fill
1580    /// the upper triangle from the lower triangle populated by `xtwx_lower`.
1581    fn launch_symmetrize_lower(
1582        stream: &std::sync::Arc<cudarc::driver::CudaStream>,
1583        ctx: &std::sync::Arc<cudarc::driver::CudaContext>,
1584        p: usize,
1585        a_dev: &mut CudaSlice<f64>,
1586    ) -> Result<(), String> {
1587        if p <= 1 {
1588            return Ok(());
1589        }
1590        let module = FUSED_XTWX_CACHE
1591            .get_or_compile(ctx, "fused_xtwx", FUSED_XTWX_PTX_SOURCE)
1592            .map_err(|e| format!("fused_xtwx module (sym): {e}"))?;
1593        let func = module
1594            .load_function("symmetrize_lower")
1595            .map_err(|e| format!("load symmetrize_lower: {e}"))?;
1596        let p_i = to_i32(p)?;
1597        let num_strict = p * (p - 1) / 2;
1598        let num_strict_u32 = u32::try_from(num_strict)
1599            .map_err(|_| format!("symmetrize_lower: num_strict {num_strict} > u32"))?;
1600        const BLOCK: u32 = 256;
1601        let grid = num_strict_u32.div_ceil(BLOCK).max(1);
1602        let cfg = cudarc::driver::LaunchConfig {
1603            grid_dim: (grid, 1, 1),
1604            block_dim: (BLOCK, 1, 1),
1605            shared_mem_bytes: 0,
1606        };
1607        let mut builder = stream.launch_builder(&func);
1608        builder.arg(a_dev);
1609        builder.arg(&p_i);
1610        // SAFETY: a_dev is p*p col-major f64; each of the num_strict threads
1611        // writes one upper-triangle entry mirrored from the lower triangle.
1612        unsafe { builder.launch(cfg) }.map_err(|e| format!("symmetrize_lower launch: {e}"))?;
1613        Ok(())
1614    }
1615
1616    /// Launch the device-side Cholesky-factor logdet kernel and download
1617    /// the single scalar result. Replaces the per-step p² host download of
1618    /// the Cholesky factor that the host-side `cholesky_logdet_from_col_major`
1619    /// required.
1620    fn cholesky_logdet_device(
1621        stream: &std::sync::Arc<cudarc::driver::CudaStream>,
1622        ctx: &std::sync::Arc<cudarc::driver::CudaContext>,
1623        p: usize,
1624        factor_dev: &CudaSlice<f64>,
1625    ) -> Result<f64, String> {
1626        let module = CHOL_LOGDET_CACHE
1627            .get_or_compile(ctx, "pirls_gpu_chol_logdet", CHOL_LOGDET_PTX_SOURCE)
1628            .map_err(|err| format!("chol_logdet module: {err}"))?;
1629        let func = module
1630            .load_function("chol_logdet_col_major")
1631            .map_err(|err| format!("chol_logdet load_function: {err}"))?;
1632        let mut out_dev = stream
1633            .alloc_zeros::<f64>(1)
1634            .map_err(|err| format!("alloc chol_logdet out: {err}"))?;
1635        let p_i = to_i32(p)?;
1636        let cfg = LaunchConfig {
1637            grid_dim: (1, 1, 1),
1638            block_dim: (1, 1, 1),
1639            shared_mem_bytes: 0,
1640        };
1641        let mut builder = stream.launch_builder(&func);
1642        builder.arg(factor_dev);
1643        builder.arg(&p_i);
1644        builder.arg(&mut out_dev);
1645        // SAFETY: serial single-thread kernel reading `p` f64 diagonal
1646        // entries from a live p*p column-major factor and writing one f64
1647        // to `out_dev`; no aliasing, no oob — `p` matches the device buffer
1648        // shape every caller passes in.
1649        unsafe { builder.launch(cfg) }.map_err(|err| format!("chol_logdet launch: {err}"))?;
1650        let out_host = stream
1651            .clone_dtoh(&out_dev)
1652            .map_err(|err| format!("download chol_logdet: {err}"))?;
1653        Ok(out_host[0])
1654    }
1655
1656    fn penalty_with_ridge(penalty: ArrayView2<'_, f64>, ridge: f64) -> Array2<f64> {
1657        let mut out = penalty.to_owned();
1658        if ridge != 0.0 {
1659            for i in 0..out.nrows().min(out.ncols()) {
1660                out[[i, i]] += ridge;
1661            }
1662        }
1663        out
1664    }
1665
1666    fn to_i32(value: usize) -> Result<i32, String> {
1667        i32::try_from(value).map_err(|_| format!("CUDA dimension {value} exceeds i32"))
1668    }
1669
1670    // ────────────────────────────────────────────────────────────────────
1671    // Stage 3.3: full device-resident PIRLS loop driver
1672    // ────────────────────────────────────────────────────────────────────
1673
1674    /// Bundled NVRTC helpers for the Stage 3.3 loop driver: axpy +
1675    /// single-block sum / linf reductions. Cached process-wide.
1676    const PIRLS_LOOP_PTX_SOURCE: &str = r#"
1677// __device__ annotation required by newer NVRTC JIT semantics (see
1678// gpu_kernels/pirls_row.rs common_device_prolog — the #2313 hardware sweep).
1679extern "C" {
1680    __device__ double fabs(double);
1681}
1682
1683extern "C" __global__ void axpy_n(
1684    double alpha,
1685    const double* __restrict__ x,
1686    double* __restrict__ y,
1687    int n
1688) {
1689    int i = blockIdx.x * blockDim.x + threadIdx.x;
1690    if (i >= n) return;
1691    y[i] += alpha * x[i];
1692}
1693
1694// Correct the projected score in place:
1695//   rhs = Qs^T score - S beta + linear_shift.
1696// `penalty_step` stores S + lm*I for the factorization, so subtracting its
1697// product and adding lm*beta recovers the model penalty S exactly.
1698extern "C" __global__ void correct_newton_rhs(
1699    double* __restrict__ rhs,
1700    const double* __restrict__ penalty_step,
1701    const double* __restrict__ beta,
1702    const double* __restrict__ linear_shift,
1703    double lm,
1704    int p,
1705    double* __restrict__ penalty_beta
1706) {
1707    int i = blockIdx.x * blockDim.x + threadIdx.x;
1708    if (i >= p) return;
1709    double s_beta = 0.0;
1710    for (int j = 0; j < p; ++j) {
1711        s_beta += penalty_step[i + j * p] * beta[j];
1712    }
1713    penalty_beta[i] = s_beta;
1714    rhs[i] += -s_beta + lm * beta[i] + linear_shift[i];
1715}
1716
1717extern "C" __global__ void apply_penalty(
1718    const double* __restrict__ penalty_step,
1719    const double* __restrict__ vector,
1720    int p,
1721    double* __restrict__ output
1722) {
1723    int i = blockIdx.x * blockDim.x + threadIdx.x;
1724    if (i >= p) return;
1725    double value = 0.0;
1726    for (int j = 0; j < p; ++j) {
1727        value += penalty_step[i + j * p] * vector[j];
1728    }
1729    output[i] = value;
1730}
1731
1732// Select the first acceptable member of the seven-point line-search ladder
1733// without exporting beta, direction, objectives, or refusal summaries.
1734//
1735// output layout:
1736//   [0] alpha, [1] accepted data deviance, [2] accepted penalized objective,
1737//   [3] halving index, [4] ||direction||_inf,
1738//   [5] first refusal row, [6] first refusal code, [7] all-refused flag.
1739extern "C" __global__ void select_alpha(
1740    const double* __restrict__ data_deviance,
1741    const unsigned int* __restrict__ refusal_summary,
1742    const double* __restrict__ beta,
1743    const double* __restrict__ direction,
1744    const double* __restrict__ penalty_beta_step,
1745    const double* __restrict__ penalty_direction_step,
1746    const double* __restrict__ linear_shift,
1747    const double* __restrict__ direction_linf,
1748    double previous_deviance,
1749    double previous_objective,
1750    double constant_shift,
1751    double lm,
1752    int p,
1753    double* __restrict__ output
1754) {
1755    if (blockIdx.x != 0 || threadIdx.x != 0) return;
1756    const double alphas[7] = {
1757        1.0, 0.5, 0.25, 0.125, 0.0625, 0.03125, 0.015625
1758    };
1759
1760    double penalty_beta = constant_shift;
1761    double linear_coeff_half = 0.0;
1762    double direction_penalty = 0.0;
1763    for (int i = 0; i < p; ++i) {
1764        double s_beta = penalty_beta_step[i] - lm * beta[i];
1765        double s_direction = penalty_direction_step[i] - lm * direction[i];
1766        penalty_beta += beta[i] * s_beta - 2.0 * beta[i] * linear_shift[i];
1767        linear_coeff_half += direction[i] * (s_beta - linear_shift[i]);
1768        direction_penalty += direction[i] * s_direction;
1769    }
1770
1771    output[0] = 0.0;
1772    output[1] = previous_deviance;
1773    output[2] = previous_objective;
1774    output[3] = 0.0;
1775    output[4] = direction_linf[0];
1776    output[5] = 4294967295.0;
1777    output[6] = 0.0;
1778    output[7] = 1.0;
1779
1780    for (int k = 0; k < 7; ++k) {
1781        unsigned int row = refusal_summary[k];
1782        unsigned int code = refusal_summary[7 + k];
1783        if (k == 0 && row != 0xffffffffu) {
1784            output[5] = (double)row;
1785            output[6] = (double)code;
1786        }
1787        if (row == 0xffffffffu) {
1788            output[7] = 0.0;
1789            double alpha = alphas[k];
1790            double objective = data_deviance[k]
1791                + penalty_beta
1792                + 2.0 * alpha * linear_coeff_half
1793                + alpha * alpha * direction_penalty;
1794            if (isfinite(objective) && objective <= previous_objective) {
1795                output[0] = alpha;
1796                output[1] = data_deviance[k];
1797                output[2] = objective;
1798                output[3] = (double)k;
1799                return;
1800            }
1801        }
1802    }
1803}
1804
1805extern "C" __global__ void deviance_sum(
1806    const double* __restrict__ d,
1807    int n,
1808    double* __restrict__ out
1809) {
1810    __shared__ double sm[1024];
1811    int tid = threadIdx.x;
1812    int bdim = blockDim.x;
1813    double acc = 0.0;
1814    for (int i = tid; i < n; i += bdim) {
1815        acc += d[i];
1816    }
1817    sm[tid] = acc;
1818    __syncthreads();
1819    for (int stride = bdim / 2; stride > 0; stride >>= 1) {
1820        if (tid < stride) sm[tid] += sm[tid + stride];
1821        __syncthreads();
1822    }
1823    if (tid == 0) out[0] = sm[0];
1824}
1825
1826extern "C" __global__ void linf_norm(
1827    const double* __restrict__ v,
1828    int p,
1829    double* __restrict__ out
1830) {
1831    __shared__ double sm[1024];
1832    int tid = threadIdx.x;
1833    int bdim = blockDim.x;
1834    double acc = 0.0;
1835    for (int i = tid; i < p; i += bdim) {
1836        double a = fabs(v[i]);
1837        if (a > acc) acc = a;
1838    }
1839    sm[tid] = acc;
1840    __syncthreads();
1841    for (int stride = bdim / 2; stride > 0; stride >>= 1) {
1842        if (tid < stride) {
1843            double r = sm[tid + stride];
1844            if (r > sm[tid]) sm[tid] = r;
1845        }
1846        __syncthreads();
1847    }
1848    if (tid == 0) out[0] = sm[0];
1849}
1850
1851extern "C" __global__ void negate_n(
1852    double* __restrict__ v,
1853    int n
1854) {
1855    int i = blockIdx.x * blockDim.x + threadIdx.x;
1856    if (i >= n) return;
1857    v[i] = -v[i];
1858}
1859
1860// Deterministically select the smallest failing row. out[0] is UINT_MAX on
1861// success, otherwise the row index; out[1] carries that row's refusal code.
1862extern "C" __global__ void status_first(
1863    const unsigned int* __restrict__ status,
1864    int n,
1865    unsigned int* __restrict__ out
1866) {
1867    __shared__ unsigned int sm_row[1024];
1868    __shared__ unsigned int sm_code[1024];
1869    int tid = threadIdx.x;
1870    int bdim = blockDim.x;
1871    unsigned int best_row = 0xffffffffu;
1872    unsigned int best_code = 0u;
1873    for (int i = tid; i < n; i += bdim) {
1874        unsigned int code = status[i];
1875        if (code != 0u && (unsigned int)i < best_row) {
1876            best_row = (unsigned int)i;
1877            best_code = code;
1878        }
1879    }
1880    sm_row[tid] = best_row;
1881    sm_code[tid] = best_code;
1882    __syncthreads();
1883    for (int stride = bdim / 2; stride > 0; stride >>= 1) {
1884        if (tid < stride && sm_row[tid + stride] < sm_row[tid]) {
1885            sm_row[tid] = sm_row[tid + stride];
1886            sm_code[tid] = sm_code[tid + stride];
1887        }
1888        __syncthreads();
1889    }
1890    if (tid == 0) {
1891        out[0] = sm_row[0];
1892        out[1] = sm_code[0];
1893    }
1894}
1895
1896// Same deterministic reduction for the alpha-major [7*n] ladder status
1897// matrix. One block handles each alpha; outputs are row[0..7), code[7..14).
1898extern "C" __global__ void status_first_ladder(
1899    const unsigned int* __restrict__ status,
1900    int n,
1901    unsigned int* __restrict__ out
1902) {
1903    __shared__ unsigned int sm_row[1024];
1904    __shared__ unsigned int sm_code[1024];
1905    int k = blockIdx.x;
1906    int tid = threadIdx.x;
1907    int bdim = blockDim.x;
1908    unsigned int best_row = 0xffffffffu;
1909    unsigned int best_code = 0u;
1910    const unsigned int* candidate = status + ((long long)k * n);
1911    for (int i = tid; i < n; i += bdim) {
1912        unsigned int code = candidate[i];
1913        if (code != 0u && (unsigned int)i < best_row) {
1914            best_row = (unsigned int)i;
1915            best_code = code;
1916        }
1917    }
1918    sm_row[tid] = best_row;
1919    sm_code[tid] = best_code;
1920    __syncthreads();
1921    for (int stride = bdim / 2; stride > 0; stride >>= 1) {
1922        if (tid < stride && sm_row[tid + stride] < sm_row[tid]) {
1923            sm_row[tid] = sm_row[tid + stride];
1924            sm_code[tid] = sm_code[tid + stride];
1925        }
1926        __syncthreads();
1927    }
1928    if (tid == 0) {
1929        out[k] = sm_row[0];
1930        out[7 + k] = sm_code[0];
1931    }
1932}
1933"#;
1934
1935    static PIRLS_LOOP_CACHE: PtxModuleCache = PtxModuleCache::new();
1936
1937    /// Per-fit device workspace for the Stage 3.3 PIRLS loop driver.
1938    ///
1939    /// Three row-kernel modes occupy separate device buffers:
1940    /// - `row_solve`: solve-row (4 fields), refreshed each Newton iteration.
1941    /// - `alpha_ladder`: candidate-objective (objective[7] + status[7*n]).
1942    /// - `row_final`: five numerical fields + status, written once at convergence.
1943    pub struct PirlsLoopWorkspace {
1944        pub beta_dev: CudaSlice<f64>,
1945        /// Fixed shifted-quadratic linear term, uploaded once per loop.
1946        pub linear_shift_dev: CudaSlice<f64>,
1947        pub eta_dev: CudaSlice<f64>,
1948        /// Solve-row buffers: `grad_eta`, `w_solver`, `deviance`, `status`.
1949        pub row_solve: crate::gpu_kernels::pirls_row::SolveRowBuffers,
1950        /// Alpha-ladder buffers: `objective[7]`, alpha-major `status[7*n]`.
1951        pub alpha_ladder: crate::gpu_kernels::pirls_row::AlphaLadderDevBuffers,
1952        /// Full production final-row buffers, written once at convergence.
1953        pub row_final: crate::gpu_kernels::pirls_row::RowOutputDevBuffers,
1954        pub direction_dev: CudaSlice<f64>,
1955        /// Parallel `(S + lm I)δ` contraction consumed by `select_alpha`.
1956        pub penalty_direction_dev: CudaSlice<f64>,
1957        pub xd_dev: CudaSlice<f64>,
1958        pub scalar_dev: CudaSlice<f64>,
1959        /// Compact alpha-selection record, written and selected entirely on
1960        /// device, then downloaded in one synchronization per Newton step.
1961        /// Layout is documented by `select_alpha`.
1962        pub alpha_selection_dev: CudaSlice<f64>,
1963        /// Fourteen u32 scratch slots: row/code pairs for one row surface or
1964        /// all seven alpha-ladder candidates.
1965        pub status_u32_dev: CudaSlice<u32>,
1966        pub n: usize,
1967        pub p: usize,
1968    }
1969
1970    impl PirlsLoopWorkspace {
1971        pub fn allocate(
1972            shared: &PirlsGpuSharedData,
1973            stream: &std::sync::Arc<cudarc::driver::CudaStream>,
1974        ) -> Result<Self, String> {
1975            let n = shared.n;
1976            let p = shared.p;
1977            let alloc_f64 = |label: &'static str, len: usize| {
1978                stream
1979                    .alloc_zeros::<f64>(len)
1980                    .map_err(|e| format!("pirls loop alloc {label}: {e}"))
1981            };
1982            Ok(Self {
1983                beta_dev: alloc_f64("beta", p)?,
1984                linear_shift_dev: alloc_f64("linear shift", p)?,
1985                eta_dev: alloc_f64("eta", n)?,
1986                row_solve: crate::gpu_kernels::pirls_row::SolveRowBuffers::allocate(stream, n)
1987                    .map_err(|e| format!("pirls loop alloc row_solve: {e}"))?,
1988                alpha_ladder: crate::gpu_kernels::pirls_row::AlphaLadderDevBuffers::allocate(
1989                    stream, n,
1990                )
1991                .map_err(|e| format!("pirls loop alloc alpha_ladder: {e}"))?,
1992                row_final: crate::gpu_kernels::pirls_row::RowOutputDevBuffers::allocate(stream, n)
1993                    .map_err(|e| format!("pirls loop alloc row_final: {e}"))?,
1994                direction_dev: alloc_f64("direction", p)?,
1995                penalty_direction_dev: alloc_f64("penalty direction", p)?,
1996                xd_dev: alloc_f64("xd", n)?,
1997                scalar_dev: alloc_f64("scalar", 1)?,
1998                alpha_selection_dev: alloc_f64("alpha selection", 8)?,
1999                status_u32_dev: stream
2000                    .alloc_zeros::<u32>(14)
2001                    .map_err(|e| format!("pirls loop alloc status_u32: {e}"))?,
2002                n,
2003                p,
2004            })
2005        }
2006    }
2007
2008    /// Optional host-side inputs that turn the bare GPU loop result
2009    /// into a full-surface `PirlsLoopOutcome` matching the CPU oracle
2010    /// `fit_model_for_fixed_rho_with_adaptive_kkt`.
2011    ///
2012    /// When supplied, the postpass at loop exit runs the same host-side
2013    /// helpers the CPU oracle uses
2014    /// (`computeworkingweight_derivatives_from_eta`,
2015    /// `compute_observed_hessian_curvature_arrays`,
2016    /// `compute_constraint_kkt_diagnostics`) so the dispatch wirer can
2017    /// plumb every field of `PirlsResult` without doing math.
2018    ///
2019    /// When `None`, the derived fields on `PirlsLoopOutcome`
2020    /// (`finalweights`, `solveweights`, `solve_dmu_deta`,
2021    /// `solve_d2mu_deta2`, `solve_d3mu_deta3`, `solve_c_array`,
2022    /// `solve_d_array`, `status`, `constraint_kkt`, `ridge_passport`,
2023    /// `firth`, `edf`, `beta_transformed`, `derivatives_unsupported`)
2024    /// take safe defaults: empty arrays, `PirlsStatus::Converged` or
2025    /// `MaxIterationsReached` reflecting `converged`, no KKT
2026    /// diagnostics, identity ridge with `objective_ridge` magnitude,
2027    /// `FirthDiagnostics::Inactive`, `edf = NaN`,
2028    /// `beta_transformed = beta`, `derivatives_unsupported = true`.
2029    /// Existing callers that do not need the CPU oracle surface can
2030    /// pass `None` and ignore the derived fields.
2031    pub struct PirlsLoopExtra<'a> {
2032        /// GLM likelihood spec the row kernel was driven by. Needed by
2033        /// `computeworkingweight_derivatives_from_eta` to produce
2034        /// `solve_dmu_deta` / `solve_d2mu_deta2` / `solve_d3mu_deta3`
2035        /// and the score-side `c` / `d` arrays.
2036        pub likelihood: &'a gam_problem::GlmLikelihoodSpec,
2037        /// Inverse link the row kernel was driven by; pairs with
2038        /// `likelihood` for the family-specific derivatives.
2039        pub inverse_link: &'a gam_problem::InverseLink,
2040        /// Response vector `y` (length `n`) — same view passed to the
2041        /// row kernel. Needed for observed-curvature finalization.
2042        pub y: ndarray::ArrayView1<'a, f64>,
2043        /// Prior weights (length `n`) — same view passed to the row
2044        /// kernel. Carried through to the curvature helpers.
2045        pub priorweights: ndarray::ArrayView1<'a, f64>,
2046        /// Observation offset (length `n`). Stored verbatim on the
2047        /// outcome's `final_offset` so the dispatch wirer can populate
2048        /// `PirlsResult::final_offset` without re-allocating.
2049        pub offset: ndarray::ArrayView1<'a, f64>,
2050        /// Linear inequality constraints `A·β ≥ b` in the same
2051        /// coordinate frame as the GPU loop's β. When `Some`, the
2052        /// postpass calls `compute_constraint_kkt_diagnostics` on the
2053        /// converged β + reconstructed penalised gradient and emits
2054        /// the result on `PirlsLoopOutcome::constraint_kkt`. When
2055        /// `None`, no diagnostics are produced.
2056        pub linear_constraints: Option<&'a gam_problem::LinearInequalityConstraints>,
2057        /// Curvature surface the *outer* REML / LAML caller expects on
2058        /// the returned Hessian. The GPU loop runs under whatever
2059        /// `curvature: CurvatureMode` it was invoked with; if this
2060        /// differs (e.g. inner loop ran Fisher for stability but the
2061        /// outer caller demands observed curvature), the postpass
2062        /// promotes `finalweights` / `solve_c_array` / `solve_d_array`
2063        /// via `compute_observed_hessian_curvature_arrays` so the
2064        /// outcome matches the CPU oracle's `exported_laplace_curvature`
2065        /// contract.
2066        pub exported_curvature: crate::pirls::HessianCurvatureKind,
2067        /// Pre-built ridge passport carrying the stabilization
2068        /// magnitude + policy that the dispatch wirer wants stamped on
2069        /// `PirlsResult::ridge_passport`. When `None`, the postpass
2070        /// uses `RidgePassport::scaled_identity(objective_ridge,
2071        /// RidgePolicy::explicit_stabilization_full())`, which mirrors
2072        /// the CPU oracle's default for a no-escalation fit.
2073        pub ridge_passport: Option<gam_problem::RidgePassport>,
2074        /// Firth bias-reduction diagnostics. Today the GPU loop does
2075        /// not implement Firth; pass `None` to land
2076        /// `FirthDiagnostics::Inactive` on the outcome. A future
2077        /// device-side Firth path would populate this with the active
2078        /// Jeffreys-logdet + hat-diagonal vector.
2079        pub firth: Option<crate::pirls::FirthDiagnostics>,
2080        /// Effective degrees of freedom at the converged mode, when
2081        /// the dispatch wirer has it precomputed (typical case: the
2082        /// outer REML caller passes its own `e_transformed` /
2083        /// diagonal-penalty pre-image and computes EDF host-side).
2084        /// When `None`, the postpass emits `f64::NAN` and sets
2085        /// `derivatives_unsupported = true` — the dispatch wirer can
2086        /// then compute EDF itself from `penalized_hessian` and the
2087        /// caller-side penalty root.
2088        pub edf: Option<f64>,
2089    }
2090
2091    #[derive(Clone, Debug)]
2092    pub struct PirlsLoopOutcome {
2093        pub beta: Array1<f64>,
2094        pub penalized_hessian: Array2<f64>,
2095        pub logdet: f64,
2096        pub deviance: f64,
2097        pub iterations: usize,
2098        pub converged: bool,
2099        /// Final linear predictor η = X·β at the accepted PIRLS step
2100        /// (length `n`). Downloaded once at loop exit.
2101        pub final_eta: Array1<f64>,
2102        /// Mean response μ = g⁻¹(η) at the accepted step, length `n`.
2103        /// Maps to `PirlsResult::finalmu` / `solvemu`.
2104        pub final_mu: Array1<f64>,
2105        /// Score-side gradient contribution `∂ℓ/∂η_i` at the accepted
2106        /// step (length `n`). The CPU oracle uses this to form
2107        /// `score_norm = ‖Xᵀ grad_eta‖₂`.
2108        pub final_grad_eta: Array1<f64>,
2109        /// Hessian-side diagonal working weight `w_hessian_i` at the
2110        /// accepted step. Maps to `PirlsResult::finalweights` when no
2111        /// observed-curvature promotion is requested.
2112        pub final_w_hessian: Array1<f64>,
2113        /// Score-side diagonal working weight `w_solver_i` at the
2114        /// accepted step. Maps to `PirlsResult::solveweights`.
2115        pub final_w_solver: Array1<f64>,
2116        /// Observation offset (length `n`). Echoed from
2117        /// `PirlsLoopExtra::offset` when supplied, otherwise an empty
2118        /// array. Maps to `PirlsResult::final_offset`.
2119        pub final_offset: Array1<f64>,
2120        /// β in the canonical transformed basis. Always equals
2121        /// `beta` because the GPU loop solved in the transformed
2122        /// design `X·Qs`, so the loop's β is already transformed.
2123        /// Maps to `PirlsResult::beta_transformed`.
2124        pub beta_transformed: Array1<f64>,
2125        /// Hessian-side `finalweights` after optional Fisher→observed
2126        /// promotion driven by `extra.exported_curvature`. Empty when
2127        /// `extra` is `None`.
2128        pub finalweights: Array1<f64>,
2129        /// Score-side `solveweights` (= `final_w_solver`) echoed
2130        /// through so the dispatch wirer can stamp directly.
2131        pub solveweights: Array1<f64>,
2132        /// Solve-side `dμ/dη` at the converged η, family-specific.
2133        /// From `computeworkingweight_derivatives_from_eta`. Empty
2134        /// when `extra` is `None`.
2135        pub solve_dmu_deta: Array1<f64>,
2136        /// Solve-side `d²μ/dη²`. Empty when `extra` is `None`.
2137        pub solve_d2mu_deta2: Array1<f64>,
2138        /// Solve-side `d³μ/dη³`. Empty when `extra` is `None`.
2139        pub solve_d3mu_deta3: Array1<f64>,
2140        /// `c_i = dW_i/dη_i` at the converged mode (Fisher or
2141        /// observed depending on `extra.exported_curvature`). Maps to
2142        /// `PirlsResult::solve_c_array`. Empty when `extra` is `None`.
2143        pub solve_c_array: Array1<f64>,
2144        /// `d_i = d²W_i/dη_i²`. Maps to `PirlsResult::solve_d_array`.
2145        /// Empty when `extra` is `None`.
2146        pub solve_d_array: Array1<f64>,
2147        /// `true` when the family's analytic 3rd/4th derivatives are
2148        /// not supported and the c/d arrays are placeholders. Mirrors
2149        /// `PirlsResult::derivatives_unsupported`.
2150        pub derivatives_unsupported: bool,
2151        /// PirlsStatus the dispatch wirer should propagate. Emitted as
2152        /// `Converged` when the loop's tolerance test passed and
2153        /// `final_eta`/`final_mu` are finite; `Unstable` when any of
2154        /// those go non-finite; `MaxIterationsReached` when the loop
2155        /// hit its iteration cap without converging.
2156        pub status: crate::pirls::PirlsStatus,
2157        /// Ridge passport carrying the stabilization δ and policy.
2158        /// When `extra.ridge_passport` is `Some`, this is the supplied
2159        /// value verbatim. Otherwise a default `scaled_identity(
2160        /// objective_ridge, explicit_stabilization_full())` passport.
2161        pub ridge_passport: gam_problem::RidgePassport,
2162        /// Firth diagnostics. `Inactive` unless the caller passes an
2163        /// `Active` value through `extra.firth`.
2164        pub firth: crate::pirls::FirthDiagnostics,
2165        /// KKT diagnostics for `extra.linear_constraints`. `None`
2166        /// either when no constraints are supplied or when the
2167        /// constraint system is empty.
2168        pub constraint_kkt: Option<crate::active_set::ConstraintKktDiagnostics>,
2169        /// Effective degrees of freedom. Echoed from `extra.edf`;
2170        /// `f64::NAN` when not supplied.
2171        pub edf: f64,
2172        /// `prev_deviance − accepted_deviance` at the accepted step
2173        /// that terminated the loop. Matches the CPU oracle's
2174        /// `WorkingModelPirlsResult::last_deviance_change`.
2175        pub last_deviance_change: f64,
2176        /// Number of line-search halvings consumed on the accepted
2177        /// step (`k` when α = `0.5^k`; `0` when α = 1). When the
2178        /// ladder was fully exhausted (`step_search_exhausted`), this
2179        /// is `0` and `last_step_size = 0.0` — no step was committed.
2180        /// Mirrors `WorkingModelPirlsResult::last_step_halving`.
2181        pub last_step_halving: usize,
2182        /// Step size α that was accepted at the final iteration.
2183        /// Mirrors `WorkingModelPirlsResult::last_step_size`.
2184        pub last_step_size: f64,
2185        /// Levenberg-Marquardt damping coefficient (step_lm_lambda) in
2186        /// effect at the last accepted iter. The GPU loop has no
2187        /// on-device ridge escalation (it is a constant per call), so
2188        /// this echoes the input `step_lm_lambda`. Maps to
2189        /// `PirlsResult::final_lm_lambda`.
2190        pub final_lm_lambda: f64,
2191        /// Running minimum of the data-side deviance observed across
2192        /// all accepted Newton steps. The GPU loop only knows the
2193        /// data deviance device-side; the dispatch wirer can add
2194        /// `βᵀ·penalty_hessian·β` at the converged β to obtain the
2195        /// fully penalised running minimum when needed for
2196        /// `PirlsResult::min_penalized_deviance`.
2197        pub min_deviance: f64,
2198        /// `max_i |η_i|` at the accepted final step — the saturation
2199        /// diagnostic the CPU oracle stamps on
2200        /// `PirlsResult::max_abs_eta`. Used by REML's
2201        /// perfect-separation detection.
2202        pub max_abs_eta: f64,
2203    }
2204
2205    /// Full device-resident PIRLS loop. Candidate deviances, refusal summaries,
2206    /// direction, beta, and shifted-penalty algebra stay on device; one compact
2207    /// alpha decision record synchronizes the host per Newton iteration. Beta
2208    /// and the final Hessian are downloaded once at exit.
2209    pub(super) fn pirls_loop(
2210        shared: &PirlsGpuSharedData,
2211        ws: &mut SigmaPirlsGpuWorkspace,
2212        loop_ws: &mut PirlsLoopWorkspace,
2213        family: crate::gpu_kernels::pirls_row::PirlsRowFamily,
2214        curvature: crate::gpu_kernels::pirls_row::CurvatureMode,
2215        // Active Gamma dispersion shape (α > 0). Forwarded to every
2216        // `launch_row_reweight_on_stream` call. Pass `1.0` for non-Gamma fits.
2217        gamma_shape: f64,
2218        beta0_host: ArrayView1<'_, f64>,
2219        penalty_hessian: ArrayView2<'_, f64>,
2220        // Linear shift `b` of the shifted-quadratic penalty
2221        // `βᵀSβ − 2βᵀb + c`. Length `p`. Mirrors
2222        // `PirlsPenalty::linear_shift()` in the CPU oracle. Pass a zero
2223        // vector for fits with no prior-mean shift.
2224        linear_shift: ArrayView1<'_, f64>,
2225        // Constant shift `c` of the shifted-quadratic penalty. Pass
2226        // `0.0` for fits with no prior-mean shift.
2227        constant_shift: f64,
2228        // Temporary LM damping for the Newton solves only; never enters
2229        // RidgePassport / exported Hessian / EDF / penalty term.
2230        lm_ridge: f64,
2231        // Real model-objective ridge; enters RidgePassport / exported
2232        // Hessian / EDF / penalty term.
2233        objective_ridge: f64,
2234        max_iter: usize,
2235        tol: f64,
2236        extra: Option<&PirlsLoopExtra<'_>>,
2237    ) -> Result<PirlsLoopOutcome, PirlsGpuLoopError> {
2238        let n = shared.n;
2239        let p = shared.p;
2240        if loop_ws.n != n || loop_ws.p != p {
2241            return Err(format!(
2242                "loop workspace ({}, {}) ≠ shared ({n}, {p})",
2243                loop_ws.n, loop_ws.p
2244            )
2245            .into());
2246        }
2247        if beta0_host.len() != p {
2248            return Err(format!("beta0 length {} ≠ p={p}", beta0_host.len()).into());
2249        }
2250
2251        if linear_shift.len() != p {
2252            return Err(format!("linear_shift length {} ≠ p={p}", linear_shift.len()).into());
2253        }
2254        if penalty_hessian.dim() != (p, p) {
2255            return Err(format!(
2256                "penalty_hessian shape {:?} ≠ (p={p}, p={p})",
2257                penalty_hessian.dim()
2258            )
2259            .into());
2260        }
2261
2262        ws.stream
2263            .memcpy_htod(
2264                beta0_host.as_slice().ok_or("beta0 not contiguous")?,
2265                &mut loop_ws.beta_dev,
2266            )
2267            .map_err(|e| format!("upload beta0: {e}"))?;
2268        ws.stream
2269            .memcpy_htod(
2270                linear_shift
2271                    .as_slice()
2272                    .ok_or("linear_shift not contiguous")?,
2273                &mut loop_ws.linear_shift_dev,
2274            )
2275            .map_err(|e| format!("upload linear_shift: {e}"))?;
2276
2277        let backend = crate::gpu_kernels::pirls_row::PirlsRowBackend::probe()
2278            .map_err(|e| format!("pirls_row backend: {e}"))?;
2279        let loop_module = PIRLS_LOOP_CACHE
2280            .get_or_compile(&shared.ctx, "pirls_loop", PIRLS_LOOP_PTX_SOURCE)
2281            .map_err(|e| format!("pirls loop module: {e}"))?;
2282        let axpy_func = loop_module
2283            .load_function("axpy_n")
2284            .map_err(|e| format!("load axpy_n: {e}"))?;
2285        let sum_func = loop_module
2286            .load_function("deviance_sum")
2287            .map_err(|e| format!("load deviance_sum: {e}"))?;
2288        let linf_func = loop_module
2289            .load_function("linf_norm")
2290            .map_err(|e| format!("load linf_norm: {e}"))?;
2291        let status_first_func = loop_module
2292            .load_function("status_first")
2293            .map_err(|e| format!("load status_first: {e}"))?;
2294        let status_first_ladder_func = loop_module
2295            .load_function("status_first_ladder")
2296            .map_err(|e| format!("load status_first_ladder: {e}"))?;
2297        let select_alpha_func = loop_module
2298            .load_function("select_alpha")
2299            .map_err(|e| format!("load select_alpha: {e}"))?;
2300        let apply_penalty_func = loop_module
2301            .load_function("apply_penalty")
2302            .map_err(|e| format!("load apply_penalty: {e}"))?;
2303
2304        // beta_orig = Qs · beta  (transforms from transformed to original coords).
2305        // For identity Qs, this is a copy; always goes through ws.beta_orig_dev.
2306        gemv_no_trans(
2307            &ws.blas,
2308            p,
2309            p,
2310            &ws.qs_dev,
2311            &loop_ws.beta_dev,
2312            &mut ws.beta_orig_dev,
2313        )?;
2314        // η = X_original · beta_orig  then η += offset (#258).
2315        gemv_no_trans(
2316            &ws.blas,
2317            n,
2318            p,
2319            &shared.x_original_dev,
2320            &ws.beta_orig_dev,
2321            &mut loop_ws.eta_dev,
2322        )?;
2323        axpy(
2324            &ws.stream,
2325            &axpy_func,
2326            1.0,
2327            &shared.offset_dev,
2328            &mut loop_ws.eta_dev,
2329            n,
2330        )?;
2331        // Initial solve-row pass on the starting η (4-output kernel only).
2332        crate::gpu_kernels::pirls_row::launch_solve_row_on_stream(
2333            backend,
2334            family,
2335            curvature,
2336            gamma_shape,
2337            &ws.stream,
2338            n,
2339            &loop_ws.eta_dev,
2340            &shared.y_dev,
2341            &shared.prior_w_dev,
2342            &mut loop_ws.row_solve,
2343        )
2344        .map_err(|e| format!("solve-row init: {e}"))?;
2345        certify_device_rows(
2346            &ws.stream,
2347            &status_first_func,
2348            &loop_ws.row_solve.status,
2349            &mut loop_ws.status_u32_dev,
2350            family,
2351            curvature,
2352            gamma_shape,
2353            &loop_ws.eta_dev,
2354            &shared.y_dev,
2355            &shared.prior_w_dev,
2356            n,
2357            "solve-row init",
2358        )?;
2359
2360        let mut prev_deviance = reduce_scalar(
2361            &ws.stream,
2362            &sum_func,
2363            &loop_ws.row_solve.deviance,
2364            n,
2365            &mut loop_ws.scalar_dev,
2366            "deviance_init",
2367        )?;
2368        let mut last_logdet = 0.0_f64;
2369        let mut converged = false;
2370
2371        // Initial *penalized* objective = data-deviance(β₀) + shifted
2372        // quadratic(β₀). This is the value the line search and
2373        // convergence test compare candidates against — matches the CPU
2374        // oracle's `penalized_objective` in `CandidateScreen`.
2375        let s_beta0 = penalty_hessian.dot(&beta0_host);
2376        let penalty_init =
2377            beta0_host.dot(&s_beta0) - 2.0 * beta0_host.dot(&linear_shift) + constant_shift;
2378        let mut prev_objective = prev_deviance + penalty_init;
2379
2380        // Diagnostic scalars surfaced on the outcome so the dispatch
2381        // wirer can populate WorkingModelPirlsResult / PirlsResult
2382        // fields without re-running the loop. They mirror the CPU
2383        // oracle's per-iter tracking in runworking_model_pirls; the
2384        // "deviance change" diagnostic now carries the *penalized*
2385        // objective delta (matches the CPU oracle's convergence-test
2386        // input and what the issue requested).
2387        let mut last_dev_delta = 0.0_f64;
2388        let mut last_halving: usize = 0;
2389        let mut last_step_size = 0.0_f64;
2390        let mut min_dev = prev_deviance;
2391        let mut step_search_exhausted = false;
2392
2393        for it in 0..max_iter {
2394            last_logdet = solve_step_on_stream_device_inplace(
2395                shared,
2396                ws,
2397                PirlsStepStreamDeviceInput {
2398                    w_solver_dev: &loop_ws.row_solve.w_solver,
2399                    grad_eta_dev: &loop_ws.row_solve.grad_eta,
2400                    penalty_hessian,
2401                    step_lm_lambda: lm_ridge,
2402                    objective_ridge,
2403                    beta_dev: &loop_ws.beta_dev,
2404                    linear_shift,
2405                },
2406            )
2407            .map_err(|e| format!("inner step it={it}: {e}"))?;
2408            // ws.rhs_dev holds the Newton descent direction δ = H⁻¹·rhs (#257).
2409            // Copy device-to-device: no host round-trip.
2410            ws.stream
2411                .memcpy_dtod(&ws.rhs_dev, &mut loop_ws.direction_dev)
2412                .map_err(|e| format!("direction d2d copy it={it}: {e}"))?;
2413
2414            launch_scalar_reduction(
2415                &ws.stream,
2416                &linf_func,
2417                &loop_ws.direction_dev,
2418                p,
2419                &mut loop_ws.scalar_dev,
2420                "dir_linf",
2421            )?;
2422
2423            // dir_orig = Qs · direction (transform direction to original coords).
2424            gemv_no_trans(
2425                &ws.blas,
2426                p,
2427                p,
2428                &ws.qs_dev,
2429                &loop_ws.direction_dev,
2430                &mut ws.dir_orig_dev,
2431            )?;
2432            gemv_no_trans(
2433                &ws.blas,
2434                n,
2435                p,
2436                &shared.x_original_dev,
2437                &ws.dir_orig_dev,
2438                &mut loop_ws.xd_dev,
2439            )?;
2440
2441            // -- Fused alpha-ladder (candidate-objective mode) ----------------
2442            // One kernel launch evaluates eta + alpha_k*xdelta for all k in
2443            // ALPHA_LADDER simultaneously, atomically accumulating per-row
2444            // deviance into objective_dev[k] and writing exact per-row refusal
2445            // codes. A deterministic device reduction returns seven row/code
2446            // pairs to device memory; `select_alpha` combines those with the
2447            // exact shifted-quadratic penalty and direction norm. Only its
2448            // compact decision record crosses to the host.
2449            loop_ws
2450                .alpha_ladder
2451                .zero(&ws.stream)
2452                .map_err(|e| format!("ladder zero it={it}: {e}"))?;
2453            crate::gpu_kernels::pirls_row::launch_alpha_ladder_on_stream(
2454                backend,
2455                family,
2456                curvature,
2457                gamma_shape,
2458                &ws.stream,
2459                n,
2460                &loop_ws.eta_dev,
2461                &loop_ws.xd_dev,
2462                &shared.y_dev,
2463                &shared.prior_w_dev,
2464                &mut loop_ws.alpha_ladder,
2465            )
2466            .map_err(|e| format!("alpha-ladder it={it}: {e}"))?;
2467            launch_ladder_status_first_reduction(
2468                &ws.stream,
2469                &status_first_ladder_func,
2470                &loop_ws.alpha_ladder.status_dev,
2471                n,
2472                &mut loop_ws.status_u32_dev,
2473            )?;
2474            let p_i = to_i32(p)?;
2475            let contraction_cfg = LaunchConfig {
2476                grid_dim: ((p as u32).div_ceil(256).max(1), 1, 1),
2477                block_dim: (256, 1, 1),
2478                shared_mem_bytes: 0,
2479            };
2480            let mut contraction_builder = ws.stream.launch_builder(&apply_penalty_func);
2481            contraction_builder.arg(&ws.penalty_dev);
2482            contraction_builder.arg(&loop_ws.direction_dev);
2483            contraction_builder.arg(&p_i);
2484            contraction_builder.arg(&mut loop_ws.penalty_direction_dev);
2485            // SAFETY: apply_penalty covers p output rows and reads a
2486            // column-major p×p matrix plus one p-vector.
2487            unsafe { contraction_builder.launch(contraction_cfg) }
2488                .map_err(|e| format!("apply penalty to direction it={it}: {e}"))?;
2489            let selection_cfg = LaunchConfig {
2490                grid_dim: (1, 1, 1),
2491                block_dim: (1, 1, 1),
2492                shared_mem_bytes: 0,
2493            };
2494            let mut builder = ws.stream.launch_builder(&select_alpha_func);
2495            builder.arg(&loop_ws.alpha_ladder.objective_dev);
2496            builder.arg(&loop_ws.status_u32_dev);
2497            builder.arg(&loop_ws.beta_dev);
2498            builder.arg(&loop_ws.direction_dev);
2499            builder.arg(&ws.beta_orig_dev);
2500            builder.arg(&loop_ws.penalty_direction_dev);
2501            builder.arg(&loop_ws.linear_shift_dev);
2502            builder.arg(&loop_ws.scalar_dev);
2503            builder.arg(&prev_deviance);
2504            builder.arg(&prev_objective);
2505            builder.arg(&constant_shift);
2506            builder.arg(&lm_ridge);
2507            builder.arg(&p_i);
2508            builder.arg(&mut loop_ws.alpha_selection_dev);
2509            // SAFETY: select_alpha is a single-thread coefficient-space
2510            // reduction over p-sized vectors and the p×p penalty, with seven
2511            // ladder objectives and fourteen refusal-summary inputs.
2512            unsafe { builder.launch(selection_cfg) }
2513                .map_err(|e| format!("select alpha on device it={it}: {e}"))?;
2514            let selection = ws
2515                .stream
2516                .clone_dtoh(&loop_ws.alpha_selection_dev)
2517                .map_err(|e| format!("download alpha selection it={it}: {e}"))?;
2518            let alpha = selection[0];
2519            let accepted_dev = selection[1];
2520            let accepted_objective = selection[2];
2521            let halving_count = selection[3] as usize;
2522            let dir_linf = selection[4];
2523            let all_candidates_refused = selection[7] != 0.0;
2524            if alpha == 0.0 {
2525                if all_candidates_refused {
2526                    let row = selection[5] as usize;
2527                    let code = selection[6] as u32;
2528                    let eta_host = ws
2529                        .stream
2530                        .clone_dtoh(&loop_ws.eta_dev)
2531                        .map_err(|error| format!("ladder refusal eta download: {error}"))?;
2532                    let xd_host = ws
2533                        .stream
2534                        .clone_dtoh(&loop_ws.xd_dev)
2535                        .map_err(|error| format!("ladder refusal direction download: {error}"))?;
2536                    let y_host = ws
2537                        .stream
2538                        .clone_dtoh(&shared.y_dev)
2539                        .map_err(|error| format!("ladder refusal response download: {error}"))?;
2540                    let prior_host =
2541                        ws.stream.clone_dtoh(&shared.prior_w_dev).map_err(|error| {
2542                            format!("ladder refusal prior-weight download: {error}")
2543                        })?;
2544                    let trial_eta = eta_host[row]
2545                        + crate::gpu_kernels::pirls_row::ALPHA_LADDER[0] * xd_host[row];
2546                    return Err(replay_row_refusal(
2547                        family,
2548                        curvature,
2549                        gamma_shape,
2550                        row,
2551                        code,
2552                        trial_eta,
2553                        y_host[row],
2554                        prior_host[row],
2555                    ));
2556                }
2557                // No α in the ladder produced a step lowering the
2558                // *penalized* objective. The previous code (and the
2559                // first draft of this rewrite) silently committed
2560                // α=1 here and merely *flagged* exhaustion — that
2561                // still commits a non-descent step, which is exactly
2562                // what the issue forbids (#263).
2563                //
2564                // Signal exhaustion and exit the inner loop without
2565                // committing β / η / solve-row buffers;
2566                // `build_loop_outcome` then maps
2567                // `step_search_exhausted` to
2568                // `PirlsStatus::LmStepSearchExhausted`, exactly the
2569                // CPU oracle's "no acceptable step direction even
2570                // after damping" signal. The outer REML / LM
2571                // controller can raise damping or reject the outer
2572                // iteration. β / η / prev_deviance / prev_objective
2573                // all stay at their last accepted values; the
2574                // device buffers are likewise untouched.
2575                step_search_exhausted = true;
2576                last_halving = 0;
2577                last_step_size = 0.0;
2578                last_dev_delta = 0.0;
2579                break;
2580            }
2581            step_search_exhausted = false;
2582            // Commit accepted step: beta and eta updated in-place.
2583            axpy(
2584                &ws.stream,
2585                &axpy_func,
2586                alpha,
2587                &loop_ws.direction_dev,
2588                &mut loop_ws.beta_dev,
2589                p,
2590            )?;
2591            axpy(
2592                &ws.stream,
2593                &axpy_func,
2594                alpha,
2595                &loop_ws.xd_dev,
2596                &mut loop_ws.eta_dev,
2597                n,
2598            )?;
2599            // Refresh the 4-output solve-row buffers for the next Newton iter.
2600            crate::gpu_kernels::pirls_row::launch_solve_row_on_stream(
2601                backend,
2602                family,
2603                curvature,
2604                gamma_shape,
2605                &ws.stream,
2606                n,
2607                &loop_ws.eta_dev,
2608                &shared.y_dev,
2609                &shared.prior_w_dev,
2610                &mut loop_ws.row_solve,
2611            )
2612            .map_err(|e| format!("solve-row accepted it={it}: {e}"))?;
2613            certify_device_rows(
2614                &ws.stream,
2615                &status_first_func,
2616                &loop_ws.row_solve.status,
2617                &mut loop_ws.status_u32_dev,
2618                family,
2619                curvature,
2620                gamma_shape,
2621                &loop_ws.eta_dev,
2622                &shared.y_dev,
2623                &shared.prior_w_dev,
2624                n,
2625                "solve-row accepted",
2626            )?;
2627
2628            let step_norm = alpha.abs() * dir_linf;
2629            let dev_delta = (prev_objective - accepted_objective).abs();
2630            last_dev_delta = dev_delta;
2631            last_halving = halving_count;
2632            last_step_size = alpha;
2633            if accepted_dev < min_dev {
2634                min_dev = accepted_dev;
2635            }
2636
2637            prev_deviance = accepted_dev;
2638            prev_objective = accepted_objective;
2639
2640            if dir_linf <= tol
2641                && step_norm <= tol
2642                && dev_delta <= tol * (1.0 + prev_objective.abs())
2643            {
2644                converged = true;
2645                // Final-row mode: write the full production row surface once.
2646                crate::gpu_kernels::pirls_row::launch_row_reweight_on_stream(
2647                    backend,
2648                    family,
2649                    curvature,
2650                    gamma_shape,
2651                    &ws.stream,
2652                    n,
2653                    &loop_ws.eta_dev,
2654                    &shared.y_dev,
2655                    &shared.prior_w_dev,
2656                    &mut loop_ws.row_final,
2657                )
2658                .map_err(|e| format!("final-row converged: {e}"))?;
2659                certify_device_rows(
2660                    &ws.stream,
2661                    &status_first_func,
2662                    &loop_ws.row_final.status,
2663                    &mut loop_ws.status_u32_dev,
2664                    family,
2665                    curvature,
2666                    gamma_shape,
2667                    &loop_ws.eta_dev,
2668                    &shared.y_dev,
2669                    &shared.prior_w_dev,
2670                    n,
2671                    "final-row converged",
2672                )?;
2673                let h_final = rebuild_h_final(
2674                    shared,
2675                    ws,
2676                    &loop_ws.row_final.w_hessian,
2677                    penalty_hessian,
2678                    objective_ridge,
2679                )
2680                .map_err(|e| format!("rebuild H_final (converged): {e}"))?;
2681                return build_loop_outcome(
2682                    ws,
2683                    loop_ws,
2684                    h_final,
2685                    last_logdet,
2686                    prev_deviance,
2687                    it + 1,
2688                    converged,
2689                    lm_ridge,
2690                    objective_ridge,
2691                    extra,
2692                    LoopDiagnostics {
2693                        last_deviance_change: last_dev_delta,
2694                        last_step_halving: last_halving,
2695                        last_step_size,
2696                        min_deviance: min_dev,
2697                        step_search_exhausted,
2698                    },
2699                );
2700            }
2701        }
2702
2703        // Final-row mode: write the full production row surface once at exit.
2704        crate::gpu_kernels::pirls_row::launch_row_reweight_on_stream(
2705            backend,
2706            family,
2707            curvature,
2708            gamma_shape,
2709            &ws.stream,
2710            n,
2711            &loop_ws.eta_dev,
2712            &shared.y_dev,
2713            &shared.prior_w_dev,
2714            &mut loop_ws.row_final,
2715        )
2716        .map_err(|e| format!("final-row max_iter: {e}"))?;
2717        certify_device_rows(
2718            &ws.stream,
2719            &status_first_func,
2720            &loop_ws.row_final.status,
2721            &mut loop_ws.status_u32_dev,
2722            family,
2723            curvature,
2724            gamma_shape,
2725            &loop_ws.eta_dev,
2726            &shared.y_dev,
2727            &shared.prior_w_dev,
2728            n,
2729            "final-row max_iter",
2730        )?;
2731        let h_final = rebuild_h_final(
2732            shared,
2733            ws,
2734            &loop_ws.row_final.w_hessian,
2735            penalty_hessian,
2736            objective_ridge,
2737        )
2738        .map_err(|e| format!("rebuild H_final (max_iter): {e}"))?;
2739        build_loop_outcome(
2740            ws,
2741            loop_ws,
2742            h_final,
2743            last_logdet,
2744            prev_deviance,
2745            max_iter,
2746            converged,
2747            lm_ridge,
2748            objective_ridge,
2749            extra,
2750            LoopDiagnostics {
2751                last_deviance_change: last_dev_delta,
2752                last_step_halving: last_halving,
2753                last_step_size,
2754                min_deviance: min_dev,
2755                step_search_exhausted,
2756            },
2757        )
2758    }
2759
2760    /// Internal carrier for the scalar diagnostics tracked across the
2761    /// inner Newton loop. Surfaced verbatim on `PirlsLoopOutcome` so the
2762    /// dispatch wirer's plumbing to `WorkingModelPirlsResult` is a
2763    /// direct field copy.
2764    ///
2765    /// `step_search_exhausted` is the GPU mirror of the CPU oracle's
2766    /// `PirlsStatus::LmStepSearchExhausted` signal: the line-search
2767    /// halving ladder produced no step that lowered the *penalized*
2768    /// objective. When true, `build_loop_outcome` promotes the emitted
2769    /// status accordingly so the outer REML / LM controller can raise
2770    /// damping or fail the iteration cleanly instead of being handed a
2771    /// silently non-descent step.
2772    struct LoopDiagnostics {
2773        last_deviance_change: f64,
2774        last_step_halving: usize,
2775        last_step_size: f64,
2776        min_deviance: f64,
2777        step_search_exhausted: bool,
2778    }
2779
2780    /// Build a full-surface [`PirlsLoopOutcome`] from the loop's
2781    /// device-resident state plus optional caller-supplied
2782    /// [`PirlsLoopExtra`] context.
2783    ///
2784    /// Five n-vector DtoH downloads are unavoidable (η, μ, grad_η,
2785    /// w_hessian, w_solver); β is one p-vector download. When `extra`
2786    /// is `Some`, the host-side helpers
2787    /// `computeworkingweight_derivatives_from_eta` and (optionally)
2788    /// `compute_observed_hessian_curvature_arrays` produce the
2789    /// solve-side aux jets and the curvature-promoted Hessian-side
2790    /// weights; `compute_constraint_kkt_diagnostics` runs over the
2791    /// converged β and reconstructed penalised gradient. All of this
2792    /// is bit-identical to the corresponding CPU oracle code paths in
2793    /// `fit_model_for_fixed_rho_with_adaptive_kkt`.
2794    fn build_loop_outcome(
2795        ws: &mut SigmaPirlsGpuWorkspace,
2796        loop_ws: &mut PirlsLoopWorkspace,
2797        penalized_hessian: Array2<f64>,
2798        logdet: f64,
2799        deviance: f64,
2800        iterations: usize,
2801        converged: bool,
2802        step_lm_lambda: f64,
2803        objective_ridge: f64,
2804        extra: Option<&PirlsLoopExtra<'_>>,
2805        diagnostics: LoopDiagnostics,
2806    ) -> Result<PirlsLoopOutcome, PirlsGpuLoopError> {
2807        let beta = download_vec(&ws.stream, &loop_ws.beta_dev)?;
2808        let final_eta = download_vec(&ws.stream, &loop_ws.eta_dev)?;
2809        let final_mu = download_vec(&ws.stream, &loop_ws.row_final.mu)?;
2810        let final_grad_eta = download_vec(&ws.stream, &loop_ws.row_final.grad_eta)?;
2811        let final_w_hessian = download_vec(&ws.stream, &loop_ws.row_final.w_hessian)?;
2812        let final_w_solver = download_vec(&ws.stream, &loop_ws.row_final.w_solver)?;
2813
2814        // Stability classification — Unstable supersedes both
2815        // converged and MaxIterationsReached because a non-finite η /
2816        // μ at the accepted step means the line search swallowed a
2817        // divergence (saturated likelihood / perfect separation).
2818        let eta_finite = final_eta.iter().all(|v| v.is_finite());
2819        let mu_finite = final_mu.iter().all(|v| v.is_finite());
2820        let beta_finite = beta.iter().all(|v| v.is_finite());
2821        let stability_ok = eta_finite && mu_finite && beta_finite;
2822        let status = if !stability_ok {
2823            crate::pirls::PirlsStatus::Unstable
2824        } else if converged {
2825            crate::pirls::PirlsStatus::Converged
2826        } else if diagnostics.step_search_exhausted {
2827            // The α-ladder produced no step lowering the *penalized*
2828            // objective — exactly the CPU oracle's "no acceptable step
2829            // direction even after damping" signal. Distinct from the
2830            // iteration-cap exhaustion (MaxIterationsReached) so the
2831            // outer REML / LM controller can react (raise damping / try
2832            // a different curvature) rather than silently accepting an
2833            // ascent step.
2834            crate::pirls::PirlsStatus::LmStepSearchExhausted
2835        } else {
2836            crate::pirls::PirlsStatus::MaxIterationsReached
2837        };
2838
2839        // RidgePassport is built from objective_ridge only — step_lm_lambda
2840        // is a solve-only artefact and must never contaminate EDF / REML.
2841        let default_ridge = gam_problem::RidgePassport::scaled_identity(
2842            objective_ridge,
2843            gam_linalg::RidgePolicy::exact_full_objective(),
2844        )
2845        .map_err(gam_problem::EstimationError::from)?;
2846
2847        let max_abs_eta = final_eta.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
2848
2849        match extra {
2850            Some(ext) => {
2851                // Family aux jets at the converged η — bit-identical
2852                // to the CPU oracle's post-convergence finalization.
2853                let (score_c, score_d, solve_dmu_deta, solve_d2mu_deta2, solve_d3mu_deta3) =
2854                    crate::pirls::computeworkingweight_derivatives_from_eta(
2855                        ext.likelihood,
2856                        ext.inverse_link,
2857                        &final_eta,
2858                        ext.priorweights,
2859                    )
2860                    .map_err(PirlsGpuLoopError::Geometry)?;
2861
2862                let (finalweights, solve_c_array, solve_d_array) = match ext.exported_curvature {
2863                    crate::pirls::HessianCurvatureKind::Observed => {
2864                        crate::pirls::compute_observed_hessian_curvature_arrays(
2865                            ext.likelihood,
2866                            ext.inverse_link,
2867                            &final_eta,
2868                            ext.y,
2869                            &final_w_solver,
2870                            ext.priorweights,
2871                        )
2872                        .map_err(PirlsGpuLoopError::Geometry)?
2873                    }
2874                    crate::pirls::HessianCurvatureKind::Fisher => {
2875                        (final_w_solver.clone(), score_c.clone(), score_d.clone())
2876                    }
2877                };
2878
2879                // The GPU loop solves in the transformed design X·Qs, so
2880                // the loop's β is already in transformed coordinates.
2881                // beta_original = qs · beta_transformed (not applied here;
2882                // callers that need original coordinates compute it from
2883                // reparam_result.qs per the PirlsResult contract).
2884                let beta_transformed = beta.clone();
2885
2886                let constraint_kkt = ext.linear_constraints.and_then(|lin| {
2887                    if lin.a.nrows() == 0 {
2888                        return None;
2889                    }
2890                    // Reconstruct the penalised gradient at the
2891                    // converged β: g = Xᵀ(grad_eta) + S β + objective_ridge·β.
2892                    // `penalized_hessian` is already XᵀWX + S + objective_ridge·I
2893                    // (step_lm_lambda was stripped from the export), so
2894                    // H_pen·β ≈ Xᵀ·grad_eta at a KKT-feasible solution.
2895                    let grad = penalized_hessian.dot(&beta);
2896                    Some(crate::active_set::compute_constraint_kkt_diagnostics(
2897                        &beta, &grad, lin,
2898                    ))
2899                });
2900
2901                let ridge_passport = ext.ridge_passport.unwrap_or(default_ridge);
2902                let firth = ext
2903                    .firth
2904                    .clone()
2905                    .unwrap_or(crate::pirls::FirthDiagnostics::Inactive);
2906                let edf = ext.edf.unwrap_or(f64::NAN);
2907                // Mirrors CPU oracle's invariant: when
2908                // `computeworkingweight_derivatives_from_eta` returns
2909                // Ok, all five jets are real (not placeholders), so
2910                // this field is `false`. See
2911                // `src/solver/pirls.rs:6634`.
2912                let derivatives_unsupported = false;
2913
2914                Ok(PirlsLoopOutcome {
2915                    beta,
2916                    penalized_hessian,
2917                    logdet,
2918                    deviance,
2919                    iterations,
2920                    converged,
2921                    final_eta,
2922                    final_mu,
2923                    final_grad_eta,
2924                    final_w_hessian,
2925                    final_w_solver: final_w_solver.clone(),
2926                    final_offset: ext.offset.to_owned(),
2927                    beta_transformed,
2928                    finalweights,
2929                    solveweights: final_w_solver,
2930                    solve_dmu_deta,
2931                    solve_d2mu_deta2,
2932                    solve_d3mu_deta3,
2933                    solve_c_array,
2934                    solve_d_array,
2935                    derivatives_unsupported,
2936                    status,
2937                    ridge_passport,
2938                    firth,
2939                    constraint_kkt,
2940                    edf,
2941                    last_deviance_change: diagnostics.last_deviance_change,
2942                    last_step_halving: diagnostics.last_step_halving,
2943                    last_step_size: diagnostics.last_step_size,
2944                    final_lm_lambda: step_lm_lambda,
2945                    min_deviance: diagnostics.min_deviance,
2946                    max_abs_eta,
2947                })
2948            }
2949            None => {
2950                // No extra context — pirls-dispatch-wirer can do the
2951                // derived-field plumbing host-side if needed. We give
2952                // it `solveweights = final_w_solver` echoed through,
2953                // empty arrays everywhere else, and safe default
2954                // status / passport / firth so the struct is fully
2955                // populated and the wirer's match arms can rely on
2956                // every field being present.
2957                Ok(PirlsLoopOutcome {
2958                    beta: beta.clone(),
2959                    penalized_hessian,
2960                    logdet,
2961                    deviance,
2962                    iterations,
2963                    converged,
2964                    final_eta,
2965                    final_mu,
2966                    final_grad_eta,
2967                    final_w_hessian,
2968                    final_w_solver: final_w_solver.clone(),
2969                    final_offset: Array1::<f64>::zeros(0),
2970                    beta_transformed: beta,
2971                    finalweights: Array1::<f64>::zeros(0),
2972                    solveweights: final_w_solver,
2973                    solve_dmu_deta: Array1::<f64>::zeros(0),
2974                    solve_d2mu_deta2: Array1::<f64>::zeros(0),
2975                    solve_d3mu_deta3: Array1::<f64>::zeros(0),
2976                    solve_c_array: Array1::<f64>::zeros(0),
2977                    solve_d_array: Array1::<f64>::zeros(0),
2978                    derivatives_unsupported: true,
2979                    status,
2980                    ridge_passport: default_ridge,
2981                    firth: crate::pirls::FirthDiagnostics::Inactive,
2982                    constraint_kkt: None,
2983                    edf: f64::NAN,
2984                    last_deviance_change: diagnostics.last_deviance_change,
2985                    last_step_halving: diagnostics.last_step_halving,
2986                    last_step_size: diagnostics.last_step_size,
2987                    final_lm_lambda: step_lm_lambda,
2988                    min_deviance: diagnostics.min_deviance,
2989                    max_abs_eta,
2990                })
2991            }
2992        }
2993    }
2994
2995    fn gemv_no_trans(
2996        blas: &CudaBlas,
2997        n: usize,
2998        p: usize,
2999        a_dev: &CudaSlice<f64>,
3000        x_dev: &CudaSlice<f64>,
3001        y_dev: &mut CudaSlice<f64>,
3002    ) -> Result<(), String> {
3003        let n_i = to_i32(n)?;
3004        let p_i = to_i32(p)?;
3005        let cfg = GemvConfig::<f64> {
3006            trans: cublasOperation_t::CUBLAS_OP_N,
3007            m: n_i,
3008            n: p_i,
3009            alpha: 1.0,
3010            lda: n_i,
3011            incx: 1,
3012            beta: 0.0,
3013            incy: 1,
3014        };
3015        // SAFETY: a is n×p col-major lda=n; x length p incx=1; y length n incy=1.
3016        unsafe { blas.gemv(cfg, a_dev, x_dev, y_dev) }.map_err(|e| format!("dgemv no-trans: {e}"))
3017    }
3018
3019    fn axpy(
3020        stream: &std::sync::Arc<cudarc::driver::CudaStream>,
3021        func: &cudarc::driver::CudaFunction,
3022        alpha: f64,
3023        x_dev: &CudaSlice<f64>,
3024        y_dev: &mut CudaSlice<f64>,
3025        n: usize,
3026    ) -> Result<(), String> {
3027        const THREADS: u32 = 256;
3028        let n_i = to_i32(n)?;
3029        let n_u = u32::try_from(n).map_err(|_| format!("axpy n={n} > u32"))?;
3030        let grid = n_u.div_ceil(THREADS).max(1);
3031        let cfg = LaunchConfig {
3032            grid_dim: (grid, 1, 1),
3033            block_dim: (THREADS, 1, 1),
3034            shared_mem_bytes: 0,
3035        };
3036        let mut builder = stream.launch_builder(func);
3037        builder.arg(&alpha);
3038        builder.arg(x_dev);
3039        builder.arg(y_dev);
3040        builder.arg(&n_i);
3041        // SAFETY: axpy_n signature is (double, const double*, double*, int);
3042        // both vectors length n.
3043        unsafe { builder.launch(cfg) }.map_err(|e| format!("axpy launch: {e}"))?;
3044        Ok(())
3045    }
3046
3047    fn launch_scalar_reduction(
3048        stream: &std::sync::Arc<cudarc::driver::CudaStream>,
3049        func: &cudarc::driver::CudaFunction,
3050        src: &CudaSlice<f64>,
3051        len: usize,
3052        scalar_dev: &mut CudaSlice<f64>,
3053        label: &'static str,
3054    ) -> Result<(), String> {
3055        const THREADS: u32 = 1024;
3056        let len_i = to_i32(len)?;
3057        let cfg = LaunchConfig {
3058            grid_dim: (1, 1, 1),
3059            block_dim: (THREADS, 1, 1),
3060            shared_mem_bytes: 0,
3061        };
3062        let mut builder = stream.launch_builder(func);
3063        builder.arg(src);
3064        builder.arg(&len_i);
3065        builder.arg(&mut *scalar_dev);
3066        // SAFETY: kernel signature (const double*, int, double*). The
3067        // `&mut *scalar_dev` reborrow keeps `scalar_dev` available for the
3068        // caller after the asynchronous launch.
3069        unsafe { builder.launch(cfg) }.map_err(|e| format!("{label} reduce launch: {e}"))?;
3070        Ok(())
3071    }
3072
3073    fn reduce_scalar(
3074        stream: &std::sync::Arc<cudarc::driver::CudaStream>,
3075        func: &cudarc::driver::CudaFunction,
3076        src: &CudaSlice<f64>,
3077        len: usize,
3078        scalar_dev: &mut CudaSlice<f64>,
3079        label: &'static str,
3080    ) -> Result<f64, String> {
3081        launch_scalar_reduction(stream, func, src, len, scalar_dev, label)?;
3082        let host = stream
3083            .clone_dtoh(scalar_dev)
3084            .map_err(|e| format!("download {label}: {e}"))?;
3085        Ok(host[0])
3086    }
3087
3088    /// Deterministically select the smallest non-zero row status with one
3089    /// scalar-sized transfer.  Outputs `(row, refusal_code)` or `None`.
3090    fn reduce_status_first(
3091        stream: &std::sync::Arc<cudarc::driver::CudaStream>,
3092        func: &cudarc::driver::CudaFunction,
3093        src: &CudaSlice<u32>,
3094        len: usize,
3095        status_dev: &mut CudaSlice<u32>,
3096        label: &'static str,
3097    ) -> Result<Option<(usize, u32)>, String> {
3098        const THREADS: u32 = 1024;
3099        let len_i = to_i32(len)?;
3100        let cfg = LaunchConfig {
3101            grid_dim: (1, 1, 1),
3102            block_dim: (THREADS, 1, 1),
3103            shared_mem_bytes: 0,
3104        };
3105        let mut builder = stream.launch_builder(func);
3106        builder.arg(src);
3107        builder.arg(&len_i);
3108        builder.arg(&mut *status_dev);
3109        // SAFETY: status_first kernel signature (const unsigned int*, int,
3110        // unsigned int*). The output has at least two u32 slots.
3111        unsafe { builder.launch(cfg) }.map_err(|e| format!("{label} first reduce launch: {e}"))?;
3112        let host = stream
3113            .clone_dtoh(status_dev)
3114            .map_err(|e| format!("download {label}: {e}"))?;
3115        if host[0] == u32::MAX {
3116            Ok(None)
3117        } else {
3118            Ok(Some((host[0] as usize, host[1])))
3119        }
3120    }
3121
3122    /// Reduce the alpha-major `[7*n]` status matrix in one seven-block launch,
3123    /// leaving all summaries device-resident for the alpha selector.
3124    fn launch_ladder_status_first_reduction(
3125        stream: &std::sync::Arc<cudarc::driver::CudaStream>,
3126        func: &cudarc::driver::CudaFunction,
3127        src: &CudaSlice<u32>,
3128        n: usize,
3129        status_dev: &mut CudaSlice<u32>,
3130    ) -> Result<(), String> {
3131        const THREADS: u32 = 1024;
3132        let n_i = to_i32(n)?;
3133        let cfg = LaunchConfig {
3134            grid_dim: (crate::gpu_kernels::pirls_row::ALPHA_LADDER_LEN as u32, 1, 1),
3135            block_dim: (THREADS, 1, 1),
3136            shared_mem_bytes: 0,
3137        };
3138        let mut builder = stream.launch_builder(func);
3139        builder.arg(src);
3140        builder.arg(&n_i);
3141        builder.arg(&mut *status_dev);
3142        // SAFETY: status_first_ladder signature is (const u32*, int, u32*);
3143        // status_dev owns 14 slots (seven rows followed by seven codes).
3144        unsafe { builder.launch(cfg) }
3145            .map_err(|e| format!("alpha-ladder status reduction launch: {e}"))?;
3146        Ok(())
3147    }
3148
3149    fn replay_row_refusal(
3150        family: crate::gpu_kernels::pirls_row::PirlsRowFamily,
3151        curvature: crate::gpu_kernels::pirls_row::CurvatureMode,
3152        gamma_shape: f64,
3153        row: usize,
3154        code: u32,
3155        eta: f64,
3156        y: f64,
3157        prior_weight: f64,
3158    ) -> PirlsGpuLoopError {
3159        let input = crate::gpu_kernels::pirls_row::RowInput {
3160            eta,
3161            y,
3162            prior_weight,
3163        };
3164        match crate::gpu_kernels::pirls_row::row_reweight_cpu_at(
3165            row,
3166            family,
3167            curvature,
3168            input,
3169            gamma_shape,
3170        ) {
3171            Err(error) => PirlsGpuLoopError::Geometry(error),
3172            Ok(_) => PirlsGpuLoopError::Geometry(
3173                gam_problem::EstimationError::PirlsRowGeometryUnrepresentable {
3174                    row,
3175                    quantity: crate::gpu_kernels::pirls_row::status_codes::quantity(code),
3176                    eta,
3177                    value: f64::from(code),
3178                },
3179            ),
3180        }
3181    }
3182
3183    fn certify_device_rows(
3184        stream: &std::sync::Arc<cudarc::driver::CudaStream>,
3185        status_first_func: &cudarc::driver::CudaFunction,
3186        status: &CudaSlice<u32>,
3187        status_scratch: &mut CudaSlice<u32>,
3188        family: crate::gpu_kernels::pirls_row::PirlsRowFamily,
3189        curvature: crate::gpu_kernels::pirls_row::CurvatureMode,
3190        gamma_shape: f64,
3191        eta: &CudaSlice<f64>,
3192        y: &CudaSlice<f64>,
3193        prior_weight: &CudaSlice<f64>,
3194        n: usize,
3195        label: &'static str,
3196    ) -> Result<(), PirlsGpuLoopError> {
3197        let Some((_row, _code)) =
3198            reduce_status_first(stream, status_first_func, status, n, status_scratch, label)?
3199        else {
3200            return Ok(());
3201        };
3202        let eta_host = stream
3203            .clone_dtoh(eta)
3204            .map_err(|error| format!("{label} refusal eta download: {error}"))?;
3205        let y_host = stream
3206            .clone_dtoh(y)
3207            .map_err(|error| format!("{label} refusal response download: {error}"))?;
3208        let prior_host = stream
3209            .clone_dtoh(prior_weight)
3210            .map_err(|error| format!("{label} refusal prior-weight download: {error}"))?;
3211        let status_host = stream
3212            .clone_dtoh(status)
3213            .map_err(|error| format!("{label} refusal status download: {error}"))?;
3214        crate::gpu_kernels::pirls_row::replay_first_refusal(
3215            family,
3216            curvature,
3217            gamma_shape,
3218            &eta_host,
3219            &y_host,
3220            &prior_host,
3221            &status_host,
3222        )
3223        .map_err(PirlsGpuLoopError::Geometry)
3224    }
3225
3226    fn download_vec(
3227        stream: &std::sync::Arc<cudarc::driver::CudaStream>,
3228        dev: &CudaSlice<f64>,
3229    ) -> Result<Array1<f64>, String> {
3230        let host = stream
3231            .clone_dtoh(dev)
3232            .map_err(|e| format!("download vec: {e}"))?;
3233        Ok(Array1::from_vec(host))
3234    }
3235
3236    /// Result of one GPU Gaussian exact penalised least-squares solve.
3237    pub struct GaussianPlsResult {
3238        pub beta: Array1<f64>,
3239        pub penalized_hessian: Array2<f64>,
3240        pub logdet: f64,
3241    }
3242
3243    /// Exact GPU PLS for Gaussian-identity: assembles QsT A Qs + S on host,
3244    /// then runs POTRF/POTRS on device.  Replaces the PIRLS loop for this family.
3245    pub fn solve_gaussian_pls_on_stream(
3246        a_orig: ArrayView2<'_, f64>,
3247        b_orig: ArrayView1<'_, f64>,
3248        s_transformed: ArrayView2<'_, f64>,
3249        linear_shift: ArrayView1<'_, f64>,
3250        prior_mean_target: ArrayView1<'_, f64>,
3251        ridge: f64,
3252        qs: Option<ArrayView2<'_, f64>>,
3253    ) -> Result<GaussianPlsResult, String> {
3254        let p = b_orig.len();
3255        if a_orig.dim() != (p, p) {
3256            return Err(format!("A shape {:?} != ({p},{p})", a_orig.dim()));
3257        }
3258        if s_transformed.dim() != (p, p) {
3259            return Err(format!("S shape {:?} != ({p},{p})", s_transformed.dim()));
3260        }
3261        if linear_shift.len() != p {
3262            return Err(format!("linear_shift len {} != p={p}", linear_shift.len()));
3263        }
3264        if prior_mean_target.len() != p {
3265            return Err(format!(
3266                "prior_mean_target len {} != p={p}",
3267                prior_mean_target.len()
3268            ));
3269        }
3270        if let Some(qs_v) = qs {
3271            if qs_v.dim() != (p, p) {
3272                return Err(format!("qs shape {:?} != ({p},{p})", qs_v.dim()));
3273            }
3274        }
3275        let (h_rotated, rhs_base) = if let Some(qs_v) = qs {
3276            let qs_owned = qs_v.to_owned();
3277            let tmp = a_orig.dot(&qs_owned);
3278            let h = qs_owned.t().dot(&tmp);
3279            let rb = qs_owned.t().dot(&b_orig);
3280            (h, rb)
3281        } else {
3282            (a_orig.to_owned(), b_orig.to_owned())
3283        };
3284        let penalized_hessian: Array2<f64> = &h_rotated + &s_transformed;
3285        let mut regularized = penalized_hessian.clone();
3286        if ridge > 0.0 {
3287            for i in 0..p {
3288                regularized[[i, i]] += ridge;
3289            }
3290        }
3291        let mut rhs_host = rhs_base;
3292        rhs_host += &linear_shift;
3293        if ridge > 0.0 {
3294            rhs_host.scaled_add(ridge, &prior_mean_target);
3295        }
3296        let (ctx, stream) = context_and_stream()?;
3297        let solver = DnHandle::new(stream.clone())
3298            .map_err(|e| format!("cusolver init (gaussian pls): {e}"))?;
3299        let pp = p.checked_mul(p).ok_or("p*p overflow (gaussian pls)")?;
3300        let mut h_dev = stream
3301            .alloc_zeros::<f64>(pp)
3302            .map_err(|e| format!("alloc H (gaussian pls): {e}"))?;
3303        let mut rhs_dev = stream
3304            .alloc_zeros::<f64>(p)
3305            .map_err(|e| format!("alloc rhs (gaussian pls): {e}"))?;
3306        let potrf_lwork_usize = potrf_query_lwork(&solver, &stream, p)?;
3307        let potrf_lwork = i32::try_from(potrf_lwork_usize)
3308            .map_err(|_| "potrf lwork overflow (gaussian pls)".to_string())?;
3309        let mut potrf_work_dev = stream
3310            .alloc_zeros::<f64>(potrf_lwork_usize.max(1))
3311            .map_err(|e| format!("alloc potrf workspace (gaussian pls): {e}"))?;
3312        let mut potrf_info_dev = stream
3313            .alloc_zeros::<i32>(1)
3314            .map_err(|e| format!("alloc potrf info (gaussian pls): {e}"))?;
3315        let mut potrs_info_dev = stream
3316            .alloc_zeros::<i32>(1)
3317            .map_err(|e| format!("alloc potrs info (gaussian pls): {e}"))?;
3318        let reg_col = to_col_major(&regularized);
3319        stream
3320            .memcpy_htod(reg_col.as_ref(), &mut h_dev)
3321            .map_err(|e| format!("upload H (gaussian pls): {e}"))?;
3322        let rhs_slice = rhs_host
3323            .as_slice()
3324            .ok_or("rhs_host not contiguous (gaussian pls)")?;
3325        stream
3326            .memcpy_htod(rhs_slice, &mut rhs_dev)
3327            .map_err(|e| format!("upload rhs (gaussian pls): {e}"))?;
3328        potrf_in_place_reuse(
3329            &solver,
3330            &stream,
3331            p,
3332            potrf_lwork,
3333            &mut h_dev,
3334            &mut potrf_work_dev,
3335            &mut potrf_info_dev,
3336        )?;
3337        potrs_in_place_reuse(
3338            &solver,
3339            &stream,
3340            p,
3341            1,
3342            &h_dev,
3343            &mut rhs_dev,
3344            &mut potrs_info_dev,
3345        )?;
3346        let logdet = cholesky_logdet_device(&stream, &ctx, p, &h_dev)?;
3347        let beta_raw = stream
3348            .clone_dtoh(&rhs_dev)
3349            .map_err(|e| format!("download beta (gaussian pls): {e}"))?;
3350        check_deferred_potrf_info(&stream, &potrf_info_dev)?;
3351        check_deferred_potrs_info(&stream, &potrs_info_dev)?;
3352        Ok(GaussianPlsResult {
3353            beta: Array1::from_vec(beta_raw),
3354            penalized_hessian,
3355            logdet,
3356        })
3357    }
3358}
3359
3360pub fn weighted_crossprod_gpu(
3361    x: ArrayView2<'_, f64>,
3362    weights: ArrayView1<'_, f64>,
3363) -> Result<Array2<f64>, String> {
3364    #[cfg(not(target_os = "linux"))]
3365    {
3366        return cpu_fallback::weighted_crossprod_cpu(x, weights);
3367    }
3368
3369    #[cfg(target_os = "linux")]
3370    {
3371        if gam_gpu::device_runtime::GpuRuntime::resolve(gam_gpu::global_policy())
3372            .map_err(|error| error.to_string())?
3373            .is_none()
3374        {
3375            return cpu_fallback::weighted_crossprod_cpu(x, weights);
3376        }
3377        cuda::weighted_crossprod(x, weights)
3378    }
3379}
3380
3381pub fn solve_pirls_step_gpu(input: PirlsGpuInput<'_>) -> Result<PirlsGpuStep, String> {
3382    #[cfg(not(target_os = "linux"))]
3383    {
3384        return cpu_fallback::solve_step_cpu(input);
3385    }
3386
3387    #[cfg(target_os = "linux")]
3388    {
3389        if gam_gpu::device_runtime::GpuRuntime::resolve(gam_gpu::global_policy())
3390            .map_err(|error| error.to_string())?
3391            .is_none()
3392        {
3393            return cpu_fallback::solve_step_cpu(input);
3394        }
3395        cuda::solve_step(input)
3396    }
3397}
3398
3399/// Upload X_original, y, prior_w, and offset once per model and return a
3400/// shared device-resident handle reused across all ρ / σ points. All four
3401/// arrays must have the same row-count `n`. The shared handle keeps the
3402/// cached per-ordinal `CudaContext` alive so all peer workspaces bind to
3403/// the same context and can interleave on its asynchronous engines.
3404#[cfg(target_os = "linux")]
3405pub fn upload_shared_pirls_gpu(
3406    x: ndarray::ArrayView2<'_, f64>,
3407    y: ndarray::ArrayView1<'_, f64>,
3408    prior_w: ndarray::ArrayView1<'_, f64>,
3409    offset: ndarray::ArrayView1<'_, f64>,
3410) -> Result<PirlsGpuSharedData, String> {
3411    gam_gpu::device_runtime::GpuRuntime::require()
3412        .map_err(|error| format!("cannot upload shared GPU PIRLS data: {error}"))?;
3413    PirlsGpuSharedData::upload_impl(x, y, prior_w, offset)
3414}
3415
3416/// Allocate a per-stream workspace bound to a fresh non-default CUDA
3417/// stream on `shared`'s context. The cuBLAS and cuSOLVER handles are bound
3418/// to the workspace stream so peer workspaces achieve overlapped execution.
3419#[cfg(target_os = "linux")]
3420pub fn allocate_sigma_pirls_workspace(
3421    shared: &PirlsGpuSharedData,
3422) -> Result<SigmaPirlsGpuWorkspace, String> {
3423    SigmaPirlsGpuWorkspace::allocate_impl(shared)
3424}
3425
3426/// Upload the reparameterisation matrix `Qs` (p×p) for the current ρ / σ
3427/// point. Call once per ρ / σ point before calling
3428/// `pirls_loop_on_stream`. When no reparameterisation is active, pass an
3429/// identity matrix.
3430#[cfg(target_os = "linux")]
3431pub fn upload_qs_pirls(
3432    ws: &mut SigmaPirlsGpuWorkspace,
3433    qs: ndarray::ArrayView2<'_, f64>,
3434) -> Result<(), String> {
3435    cuda::upload_qs(ws, qs)
3436}
3437
3438/// Upload an identity Qs for the current ρ / σ point. Equivalent to
3439/// [`upload_qs_pirls`] with an identity matrix; avoids host allocation.
3440#[cfg(target_os = "linux")]
3441pub fn upload_qs_identity_pirls(ws: &mut SigmaPirlsGpuWorkspace) -> Result<(), String> {
3442    cuda::upload_qs_identity(ws)
3443}
3444
3445/// Stage 3.3 device-resident PIRLS loop driver. See
3446/// [`cuda::pirls_loop`] for the full per-iter contract. One compact
3447/// device-selected alpha record crosses the host boundary per Newton iteration;
3448/// β and the final penalised Hessian are downloaded once at loop exit.
3449///
3450/// `step_lm_lambda` is the Levenberg–Marquardt damping applied to each
3451/// Newton solve only; it never enters the exported `penalized_hessian`,
3452/// `RidgePassport`, EDF, or penalty term.  `objective_ridge` is the
3453/// real model ridge that enters all of those.
3454#[cfg(target_os = "linux")]
3455pub(crate) fn pirls_loop_on_stream(
3456    shared: &PirlsGpuSharedData,
3457    ws: &mut SigmaPirlsGpuWorkspace,
3458    loop_ws: &mut cuda::PirlsLoopWorkspace,
3459    family: crate::gpu_kernels::pirls_row::PirlsRowFamily,
3460    curvature: crate::gpu_kernels::pirls_row::CurvatureMode,
3461    likelihood_scale: PirlsLoopLikelihoodScale,
3462    beta0: ndarray::ArrayView1<'_, f64>,
3463    penalty_hessian: ndarray::ArrayView2<'_, f64>,
3464    // Linear shift `b` for the shifted-quadratic penalty `βᵀSβ−2βᵀb+c`.
3465    // Pass a zero-length or all-zero slice for fits with no prior-mean shift.
3466    linear_shift: ndarray::ArrayView1<'_, f64>,
3467    // Constant shift `c` for the shifted-quadratic penalty. Pass `0.0` when absent.
3468    constant_shift: f64,
3469    step_lm_lambda: f64,
3470    objective_ridge: f64,
3471    max_iter: usize,
3472    tol: f64,
3473    extra: Option<&cuda::PirlsLoopExtra<'_>>,
3474) -> Result<cuda::PirlsLoopOutcome, cuda::PirlsGpuLoopError> {
3475    let gamma_shape = likelihood_scale
3476        .kernel_argument(family)
3477        .map_err(cuda::PirlsGpuLoopError::Runtime)?;
3478    cuda::pirls_loop(
3479        shared,
3480        ws,
3481        loop_ws,
3482        family,
3483        curvature,
3484        gamma_shape,
3485        beta0,
3486        penalty_hessian,
3487        linear_shift,
3488        constant_shift,
3489        step_lm_lambda,
3490        objective_ridge,
3491        max_iter,
3492        tol,
3493        extra,
3494    )
3495}
3496
3497/// Allocate a Stage 3.3 PIRLS loop workspace bound to the same stream
3498/// as `ws` against the shared device-resident design matrix.
3499#[cfg(target_os = "linux")]
3500pub fn allocate_pirls_loop_workspace(
3501    shared: &PirlsGpuSharedData,
3502    ws: &SigmaPirlsGpuWorkspace,
3503) -> Result<cuda::PirlsLoopWorkspace, String> {
3504    cuda::PirlsLoopWorkspace::allocate(shared, &ws.stream)
3505}
3506
3507/// GPU exact penalised least-squares for Gaussian-identity models.
3508///
3509/// Public wrapper around `cuda::solve_gaussian_pls_on_stream`.  Delegates
3510/// immediately if the CUDA runtime is initialised; returns an error otherwise
3511/// so the caller can fall back to the CPU path.
3512#[cfg(target_os = "linux")]
3513pub fn solve_gaussian_pls_gpu(
3514    a_orig: ndarray::ArrayView2<'_, f64>,
3515    b_orig: ndarray::ArrayView1<'_, f64>,
3516    s_transformed: ndarray::ArrayView2<'_, f64>,
3517    linear_shift: ndarray::ArrayView1<'_, f64>,
3518    prior_mean_target: ndarray::ArrayView1<'_, f64>,
3519    ridge: f64,
3520    qs: Option<ndarray::ArrayView2<'_, f64>>,
3521) -> Result<cuda::GaussianPlsResult, String> {
3522    cuda::solve_gaussian_pls_on_stream(
3523        a_orig,
3524        b_orig,
3525        s_transformed,
3526        linear_shift,
3527        prior_mean_target,
3528        ridge,
3529        qs,
3530    )
3531}
3532
3533/// CPU fallback for the PIRLS-step GPU primitives.  When this build has no
3534/// CUDA runtime probed, the GPU entry points must still return numerically
3535/// correct results so that callers can route a single code path through
3536/// `*_gpu` while the canonical policy layer in `crate::gpu` records whether
3537/// device execution was selected. Returning `Err` here would silently force
3538/// every caller to grow an `if cuda { .. } else { .. }` branch and risk
3539/// drifting away from the GPU formula.
3540mod cpu_fallback {
3541    use super::{PirlsGpuInput, PirlsGpuStep};
3542    use crate::estimate::reml::assembly::xt_diag_x_dense_into;
3543    use faer::Side;
3544    use gam_linalg::faer_ndarray::FaerCholesky;
3545    use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
3546
3547    pub(super) fn weighted_crossprod_cpu(
3548        x: ArrayView2<'_, f64>,
3549        weights: ArrayView1<'_, f64>,
3550    ) -> Result<Array2<f64>, String> {
3551        validate(x, weights)?;
3552        let x_owned = x.to_owned();
3553        let w_owned = weights.to_owned();
3554        let mut scratch = Array2::<f64>::zeros(x_owned.dim());
3555        Ok(xt_diag_x_dense_into(&x_owned, &w_owned, &mut scratch))
3556    }
3557
3558    pub(super) fn solve_step_cpu(input: PirlsGpuInput<'_>) -> Result<PirlsGpuStep, String> {
3559        validate(input.x, input.weights)?;
3560        let (_n, p) = input.x.dim();
3561        if input.penalty_hessian.dim() != (p, p) {
3562            return Err(format!(
3563                "penalty Hessian shape {:?} does not match p={p}",
3564                input.penalty_hessian.dim()
3565            ));
3566        }
3567        if input.gradient.len() != p {
3568            return Err(format!(
3569                "gradient length {} does not match p={p}",
3570                input.gradient.len()
3571            ));
3572        }
3573        let xtwx = weighted_crossprod_cpu(input.x, input.weights)?;
3574        // Exported H_final = XᵀWX + S + objective_ridge·I.
3575        let mut penalized_hessian = xtwx.clone();
3576        penalized_hessian += &input.penalty_hessian;
3577        if input.objective_ridge != 0.0 {
3578            for i in 0..p {
3579                penalized_hessian[[i, i]] += input.objective_ridge;
3580            }
3581        }
3582        // H_step = XᵀWX + S + step_lm_lambda·I for the Newton solve only.
3583        let mut h_step = xtwx;
3584        h_step += &input.penalty_hessian;
3585        if input.step_lm_lambda != 0.0 {
3586            for i in 0..p {
3587                h_step[[i, i]] += input.step_lm_lambda;
3588            }
3589        }
3590        let factor = h_step
3591            .cholesky(Side::Lower)
3592            .map_err(|e| format!("CPU Cholesky failed in PIRLS fallback: {e:?}"))?;
3593        let g = Array1::from_iter(input.gradient.iter().copied());
3594        // No negation: `input.gradient` is the full descent-direction RHS
3595        // `Xᵀscore − S·β + linear_shift`; solving H·δ = rhs gives δ directly (#257).
3596        let direction = factor.solvevec(&g);
3597        // Logdet comes from H_step's Cholesky (the actual factored matrix).
3598        let logdet = 2.0 * factor.diag().iter().map(|v| v.ln()).sum::<f64>();
3599        Ok(PirlsGpuStep {
3600            penalized_hessian,
3601            direction,
3602            logdet,
3603        })
3604    }
3605
3606    fn validate(x: ArrayView2<'_, f64>, weights: ArrayView1<'_, f64>) -> Result<(), String> {
3607        let (n, p) = x.dim();
3608        if weights.len() != n {
3609            return Err(format!(
3610                "weights length {} does not match rows {n}",
3611                weights.len()
3612            ));
3613        }
3614        if n == 0 || p == 0 {
3615            return Err("empty design cannot be solved".to_string());
3616        }
3617        Ok(())
3618    }
3619}
3620
3621pub fn cholesky_solve_gpu(
3622    hessian: ArrayView2<'_, f64>,
3623    rhs: ArrayView2<'_, f64>,
3624) -> Result<(Array2<f64>, f64), String> {
3625    gam_gpu::solver::cholesky_solve_gpu(hessian, rhs)
3626}
3627
3628/// Solution-only mixed-precision solve (logdet discarded). Skips the redundant
3629/// fp64 POTRF so the PIRLS Newton direction solve gets the full fp32-factor
3630/// speedup; the solution is fp64-accurate via iterative refinement.
3631pub fn cholesky_solve_only_gpu(
3632    hessian: ArrayView2<'_, f64>,
3633    rhs: ArrayView2<'_, f64>,
3634) -> Result<Array2<f64>, String> {
3635    gam_gpu::solver::cholesky_solve_only_gpu(hessian, rhs)
3636}
3637
3638#[cfg(all(test, target_os = "linux"))]
3639mod pirls_loop_likelihood_scale_tests {
3640    use super::PirlsLoopLikelihoodScale;
3641    use crate::gpu_kernels::pirls_row::PirlsRowFamily;
3642
3643    #[test]
3644    fn gpu_row_scale_discriminant_rejects_family_mismatch() {
3645        assert!(
3646            PirlsLoopLikelihoodScale::non_gamma()
3647                .kernel_argument(PirlsRowFamily::GammaLog)
3648                .is_err()
3649        );
3650        let gamma = PirlsLoopLikelihoodScale::gamma_shape(2.0).expect("positive Gamma shape");
3651        assert!(gamma.kernel_argument(PirlsRowFamily::PoissonLog).is_err());
3652        assert_eq!(
3653            gamma
3654                .kernel_argument(PirlsRowFamily::GammaLog)
3655                .expect("matching Gamma contract"),
3656            2.0
3657        );
3658    }
3659
3660    #[test]
3661    fn non_gamma_kernel_scalar_is_poisoned_not_unit_scaled() {
3662        let abi_value = PirlsLoopLikelihoodScale::non_gamma()
3663            .kernel_argument(PirlsRowFamily::PoissonLog)
3664            .expect("matching non-Gamma contract");
3665        assert!(abi_value.is_nan());
3666    }
3667}
3668
3669/// CPU-fallback contract for the weighted-crossprod GPU dispatcher.
3670///
3671/// `weighted_crossprod_gpu` moved here from `gam-gpu` during the #1521 crate
3672/// carve. On a host with no usable CUDA runtime it must transparently fall back
3673/// to the dense CPU path, return `Ok`, and produce the exact XᵀWX. This guards
3674/// the panic-free / Ok-via-CPU-fallback contract previously (loosely) checked in
3675/// gam-gpu's `cpu_only_host_never_panics_on_gpu_entry_points`, which could no
3676/// longer reach the function after the carve.
3677#[cfg(test)]
3678mod weighted_crossprod_cpu_fallback_tests {
3679    use super::weighted_crossprod_gpu;
3680    use ndarray::{Array1, Array2};
3681
3682    #[test]
3683    fn weighted_crossprod_gpu_cpu_fallback_matches_dense_xtwx() {
3684        // Small, below any GPU dispatch threshold → exercises the CPU fallback
3685        // on a CPU-only host (and stays Ok on a GPU host via the same contract).
3686        let x = Array2::<f64>::from_shape_fn((4, 3), |(i, j)| (i + j) as f64 + 1.0);
3687        let w = Array1::<f64>::from_vec(vec![0.5, 1.0, 1.5, 2.0]);
3688
3689        let got = weighted_crossprod_gpu(x.view(), w.view())
3690            .expect("weighted_crossprod_gpu must return Ok via CPU fallback on a CPU-only host");
3691
3692        // Reference XᵀWX = Σ_k w_k x_k x_kᵀ, formed directly.
3693        let (n, p) = x.dim();
3694        let mut expected = Array2::<f64>::zeros((p, p));
3695        for k in 0..n {
3696            for i in 0..p {
3697                for j in 0..p {
3698                    expected[[i, j]] += w[k] * x[[k, i]] * x[[k, j]];
3699                }
3700            }
3701        }
3702
3703        assert_eq!(got.dim(), (p, p));
3704        for i in 0..p {
3705            for j in 0..p {
3706                let diff = (got[[i, j]] - expected[[i, j]]).abs();
3707                assert!(
3708                    diff <= 1e-10,
3709                    "XtWX[{i},{j}] mismatch: got vs expected diff={diff}"
3710                );
3711            }
3712        }
3713    }
3714}