Skip to main content

gam_solve/gpu_kernels/
pirls_row.rs

1//! Generic GPU PIRLS row-reweight primitives.
2//!
3//! Stage 1 of the device-resident PIRLS port: for every row `i` and a fixed
4//! exponential-family `(family, link)` pair, evaluate the working IRLS state
5//! on the GPU and write it back into device-resident output buffers. The
6//! Hessian/gradient assembly that consumes those buffers (Stage 2) and the
7//! full device-resident PIRLS iteration loop (Stage 3) plug in on top of this
8//! contract without touching the per-family math.
9//!
10//! ## Output contract (per row `i`)
11//!
12//! | Field        | Meaning                                                    |
13//! |--------------|------------------------------------------------------------|
14//! | `mu`         | Inverse-link mean μ_i = g⁻¹(η_i).                          |
15//! | `grad_eta`   | Score wrt η: ∂ℓ/∂η_i = wᵢ·(yᵢ − μᵢ)·dη/dV for canonical    |
16//! |              | links; equals priorweight·(yᵢ − μᵢ)·h'(ηᵢ)/V(μᵢ) for non-  |
17//! |              | canonical Bernoulli; equals priorweight·(yᵢ − μᵢ) for      |
18//! |              | Gaussian/Poisson and priorweight·shape·(yᵢ/μᵢ−1) for Gamma.|
19//! | `w_fisher`   | Fisher expected weight (priorweight · h'(η)² / V(μ)).      |
20//! |              | Used for inference (Var(β̂)).                              |
21//! | `w_hessian`  | Curvature weight for the Newton/Laplace Hessian.            |
22//! |              | == w_fisher on canonical links; observed correction on     |
23//! |              | non-canonical Bernoulli + Gamma-log (Stage 5 populates).   |
24//! | `w_solver`   | Exact `w_hessian` consumed by matrix assembly. Statistical  |
25//! |              | rows are never floored or sign-projected; conditioning is  |
26//! |              | exclusively an assembled-matrix ridge operation.           |
27//! | `deviance`   | Per-row deviance contribution dᵢ; sum aggregated host-side  |
28//! |              | for line search and convergence checks.                    |
29//! | `status`     | Zero on success; otherwise a refusal code. A failed thread  |
30//! |              | writes no numerical outputs.                               |
31//!
32//! Two-weight discipline is structural in the contract: the gradient is
33//! emitted **directly** and never reconstructed from an artificial working
34//! response, because that product is both unnecessary and unstable in tails.
35//!
36//! ## Per-family source layout
37//!
38//! Each `(family, link, curvature_mode)` triple has its own specialised CUDA
39//! source compiled to a dedicated module and cached. The kernel never branches
40//! on a runtime `family` enum — that pattern collapses ILP and forces the
41//! compiler to keep dead paths warm. The module cache is keyed by
42//! `(family_id, curvature_mode, precision)` so a single process compiles each
43//! kernel exactly once across all fits.
44
45use std::sync::OnceLock;
46
47use gam_gpu::gpu_error::GpuError;
48#[cfg(target_os = "linux")]
49use gam_gpu::gpu_error::GpuResultExt;
50use gam_problem::EstimationError;
51
52#[cfg(target_os = "linux")]
53use std::sync::{Arc, Mutex};
54
55#[cfg(target_os = "linux")]
56use cudarc::driver::{CudaContext, CudaModule};
57
58// ────────────────────────────────────────────────────────────────────────
59// Public selectors
60// ────────────────────────────────────────────────────────────────────────
61
62/// Which built-in `(response, link)` PIRLS family the row kernel evaluates.
63///
64/// One enum value ↔ one specialised CUDA source ↔ one cached module. Custom
65/// families come in Stage 6 via NVRTC JIT (Level A / Level B) and reuse the
66/// same host harness.
67#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
68pub enum PirlsRowFamily {
69    BernoulliLogit,
70    BernoulliProbit,
71    BernoulliCLogLog,
72    PoissonLog,
73    GaussianIdentity,
74    GammaLog,
75}
76
77impl PirlsRowFamily {
78    pub const ALL: [Self; 6] = [
79        Self::BernoulliLogit,
80        Self::BernoulliProbit,
81        Self::BernoulliCLogLog,
82        Self::PoissonLog,
83        Self::GaussianIdentity,
84        Self::GammaLog,
85    ];
86
87    pub const fn as_str(self) -> &'static str {
88        match self {
89            Self::BernoulliLogit => "bernoulli-logit",
90            Self::BernoulliProbit => "bernoulli-probit",
91            Self::BernoulliCLogLog => "bernoulli-cloglog",
92            Self::PoissonLog => "poisson-log",
93            Self::GaussianIdentity => "gaussian-identity",
94            Self::GammaLog => "gamma-log",
95        }
96    }
97
98    /// CUDA `extern "C"` entry symbol for this family's full (final-row) kernel.
99    pub const fn kernel_name(self) -> &'static str {
100        match self {
101            Self::BernoulliLogit => "pirls_row_bernoulli_logit",
102            Self::BernoulliProbit => "pirls_row_bernoulli_probit",
103            Self::BernoulliCLogLog => "pirls_row_bernoulli_cloglog",
104            Self::PoissonLog => "pirls_row_poisson_log",
105            Self::GaussianIdentity => "pirls_row_gaussian_identity",
106            Self::GammaLog => "pirls_row_gamma_log",
107        }
108    }
109
110    /// CUDA `extern "C"` entry symbol for this family's solve-row kernel
111    /// (writes only `grad_eta`, `w_solver`, `deviance`, `status`).
112    pub const fn solve_kernel_name(self) -> &'static str {
113        match self {
114            Self::BernoulliLogit => "pirls_solve_bernoulli_logit",
115            Self::BernoulliProbit => "pirls_solve_bernoulli_probit",
116            Self::BernoulliCLogLog => "pirls_solve_bernoulli_cloglog",
117            Self::PoissonLog => "pirls_solve_poisson_log",
118            Self::GaussianIdentity => "pirls_solve_gaussian_identity",
119            Self::GammaLog => "pirls_solve_gamma_log",
120        }
121    }
122
123    /// CUDA `extern "C"` entry symbol for this family's alpha-ladder kernel
124    /// (evaluates all step sizes in a single launch, outputs `objective[]` and
125    /// `status[]` per alpha slot).
126    pub const fn ladder_kernel_name(self) -> &'static str {
127        match self {
128            Self::BernoulliLogit => "pirls_ladder_bernoulli_logit",
129            Self::BernoulliProbit => "pirls_ladder_bernoulli_probit",
130            Self::BernoulliCLogLog => "pirls_ladder_bernoulli_cloglog",
131            Self::PoissonLog => "pirls_ladder_poisson_log",
132            Self::GaussianIdentity => "pirls_ladder_gaussian_identity",
133            Self::GammaLog => "pirls_ladder_gamma_log",
134        }
135    }
136}
137
138/// Curvature surface used to populate `w_hessian` / `w_solver`.
139///
140/// `Fisher` is the default and matches the CPU Stage-1 path bit-for-bit. The
141/// `Observed` mode is populated by Stage 5 for non-canonical Bernoulli and
142/// Gamma-log fits where the negative-log-likelihood Hessian uses the observed
143/// information surface instead of the Fisher expected information.
144#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
145pub enum CurvatureMode {
146    Fisher,
147    Observed,
148}
149
150impl CurvatureMode {
151    pub const fn as_str(self) -> &'static str {
152        match self {
153            Self::Fisher => "fisher",
154            Self::Observed => "observed",
155        }
156    }
157}
158
159/// Device refusal codes.  Zero is the only success value; a non-zero code
160/// means that the row kernel wrote no numerical outputs for that row.  The
161/// host deterministically selects the smallest failing row and replays the
162/// CPU oracle to recover the full typed [`EstimationError`] (including the
163/// exact offending value).
164pub mod status_codes {
165    pub const OK: u32 = 0;
166    pub const ETA_DOMAIN: u32 = 1;
167    pub const PRIOR_WEIGHT: u32 = 2;
168    pub const RESPONSE: u32 = 3;
169    pub const GAMMA_SHAPE: u32 = 4;
170    pub const INVERSE_LINK: u32 = 5;
171    pub const FISHER_WEIGHT: u32 = 6;
172    pub const OBSERVED_WEIGHT: u32 = 7;
173    pub const GRADIENT: u32 = 8;
174    pub const DEVIANCE: u32 = 9;
175    pub const FINAL_OUTPUT: u32 = 10;
176
177    pub const fn quantity(code: u32) -> &'static str {
178        match code {
179            ETA_DOMAIN => "inverse-link eta domain",
180            PRIOR_WEIGHT => "prior weight",
181            RESPONSE => "response",
182            GAMMA_SHAPE => "Gamma shape",
183            INVERSE_LINK => "inverse-link jet",
184            FISHER_WEIGHT => "Fisher weight",
185            OBSERVED_WEIGHT => "observed Hessian weight",
186            GRADIENT => "eta gradient",
187            DEVIANCE => "deviance contribution",
188            FINAL_OUTPUT => "final row output",
189            _ => "unknown GPU PIRLS refusal",
190        }
191    }
192}
193
194// ────────────────────────────────────────────────────────────────────────
195// Reference CPU evaluator (parity gate against the GPU kernel).
196//
197// These functions reproduce, byte-for-byte in f64, the formulas in
198// `src/solver/pirls.rs`'s `update_glmvectors` / `write_poisson_log_working_state`
199// / `write_gamma_log_working_state` / `write_identityworking_state`. Stage 1
200// parity tests compare the GPU buffers to these on the V100; mac builds
201// exercise only the CPU reference (the GPU launcher returns
202// `DriverLibraryUnavailable` without a CUDA runtime).
203// ────────────────────────────────────────────────────────────────────────
204
205/// Per-row inputs in scalar form.
206#[derive(Clone, Copy, Debug)]
207pub struct RowInput {
208    pub eta: f64,
209    pub y: f64,
210    pub prior_weight: f64,
211}
212
213/// Per-row outputs matching the GPU kernel contract.
214#[derive(Clone, Copy, Debug, Default)]
215pub struct RowOutput {
216    pub mu: f64,
217    pub grad_eta: f64,
218    pub w_fisher: f64,
219    pub w_hessian: f64,
220    pub w_solver: f64,
221    pub deviance: f64,
222}
223
224/// Reference CPU evaluator for one row. `mode` selects `w_hessian` curvature.
225///
226/// `gamma_shape` is the Gamma dispersion shape parameter (α > 0). It is only
227/// used when `family == GammaLog`; all other families ignore it. Pass `1.0`
228/// for non-Gamma fits.
229pub fn row_reweight_cpu(
230    family: PirlsRowFamily,
231    mode: CurvatureMode,
232    input: RowInput,
233    gamma_shape: f64,
234) -> Result<RowOutput, EstimationError> {
235    row_reweight_cpu_at(0, family, mode, input, gamma_shape)
236}
237
238/// Indexed form of [`row_reweight_cpu`], used to reproduce a device refusal
239/// with the correct row in its typed error.
240pub fn row_reweight_cpu_at(
241    row: usize,
242    family: PirlsRowFamily,
243    mode: CurvatureMode,
244    input: RowInput,
245    gamma_shape: f64,
246) -> Result<RowOutput, EstimationError> {
247    match family {
248        PirlsRowFamily::GaussianIdentity => row_gaussian_identity(row, input, mode),
249        PirlsRowFamily::PoissonLog => row_poisson_log(row, input, mode),
250        PirlsRowFamily::GammaLog => row_gamma_log(row, input, mode, gamma_shape),
251        PirlsRowFamily::BernoulliLogit => row_bernoulli_logit(row, input, mode),
252        PirlsRowFamily::BernoulliProbit => row_bernoulli_probit(row, input, mode),
253        PirlsRowFamily::BernoulliCLogLog => row_bernoulli_cloglog(row, input, mode),
254    }
255}
256
257/// Recover the typed error represented by a device status vector.  Device
258/// threads write one code per row, so scanning in index order makes concurrent
259/// failures deterministic.  The scalar CPU replay supplies the exact
260/// quantity/value payload without expanding the hot GPU ABI.
261pub fn replay_first_refusal(
262    family: PirlsRowFamily,
263    mode: CurvatureMode,
264    gamma_shape: f64,
265    eta: &[f64],
266    y: &[f64],
267    prior_weight: &[f64],
268    status: &[u32],
269) -> Result<(), EstimationError> {
270    let n = eta.len();
271    if y.len() != n || prior_weight.len() != n || status.len() != n {
272        return Err(EstimationError::InvalidInput(format!(
273            "GPU PIRLS refusal replay length mismatch: eta={n}, y={}, prior_weight={}, status={}",
274            y.len(),
275            prior_weight.len(),
276            status.len(),
277        )));
278    }
279    let Some((row, &code)) = status
280        .iter()
281        .enumerate()
282        .find(|(_, code)| **code != status_codes::OK)
283    else {
284        return Ok(());
285    };
286    let input = RowInput {
287        eta: eta[row],
288        y: y[row],
289        prior_weight: prior_weight[row],
290    };
291    match row_reweight_cpu_at(row, family, mode, input, gamma_shape) {
292        Err(error) => Err(error),
293        Ok(_) => Err(row_error(
294            row,
295            status_codes::quantity(code),
296            input.eta,
297            f64::from(code),
298        )),
299    }
300}
301
302/// Resolve `(w_fisher, observed_correction)` into the `w_hessian` value that
303/// matches the selected curvature surface. Stage 1 returns `w_fisher` for both
304/// modes (parity with the CPU PIRLS path that, today, uses Fisher weights
305/// even for non-canonical links); Stage 5 will switch the `Observed` arm to
306/// `w_fisher + observed_correction` and the call sites stay unchanged.
307#[inline]
308fn select_w_hessian(mode: CurvatureMode, w_fisher: f64, observed_correction: f64) -> f64 {
309    match mode {
310        CurvatureMode::Fisher => w_fisher,
311        CurvatureMode::Observed => w_fisher + observed_correction,
312    }
313}
314
315#[inline]
316fn row_error(row: usize, quantity: &'static str, eta: f64, value: f64) -> EstimationError {
317    EstimationError::PirlsRowGeometryUnrepresentable {
318        row,
319        quantity,
320        eta,
321        value,
322    }
323}
324
325#[inline]
326fn finite_eta(link: &'static str, eta: f64) -> Result<(), EstimationError> {
327    if eta.is_finite() {
328        Ok(())
329    } else {
330        Err(EstimationError::InverseLinkDomainViolation {
331            link,
332            eta,
333            lower: -f64::MAX,
334            upper: f64::MAX,
335        })
336    }
337}
338
339#[inline]
340fn prior_weight(row: usize, input: RowInput) -> Result<f64, EstimationError> {
341    if input.prior_weight.is_finite() && input.prior_weight >= 0.0 {
342        Ok(input.prior_weight)
343    } else {
344        Err(row_error(
345            row,
346            "prior weight",
347            input.eta,
348            input.prior_weight,
349        ))
350    }
351}
352
353#[inline]
354fn certify_output(row: usize, eta: f64, output: RowOutput) -> Result<RowOutput, EstimationError> {
355    for (quantity, value) in [
356        ("mean", output.mu),
357        ("eta gradient", output.grad_eta),
358        ("Fisher weight", output.w_fisher),
359        ("observed Hessian weight", output.w_hessian),
360        ("solver Hessian weight", output.w_solver),
361        ("deviance contribution", output.deviance),
362    ] {
363        if !value.is_finite() {
364            return Err(row_error(row, quantity, eta, value));
365        }
366    }
367    Ok(output)
368}
369
370/// Evaluate `a*b/c` while avoiding a false overflow/underflow caused solely by
371/// operation order.  At least one of the product-first or quotient-first forms
372/// is normally representable whenever the final positive f64 is; all three are
373/// tried in a fixed order so CPU/device refusal and rounding stay deterministic.
374#[inline]
375fn positive_mul_div(a: f64, b: f64, c: f64) -> f64 {
376    let product = a * b;
377    if product.is_finite() && product > 0.0 {
378        let value = product / c;
379        if value.is_finite() && value > 0.0 {
380            return value;
381        }
382    }
383    let quotient_a = a / c;
384    if quotient_a.is_finite() && quotient_a > 0.0 {
385        let value = quotient_a * b;
386        if value.is_finite() && value > 0.0 {
387            return value;
388        }
389    }
390    let quotient_b = b / c;
391    if quotient_b.is_finite() && quotient_b > 0.0 {
392        let value = quotient_b * a;
393        if value.is_finite() && value > 0.0 {
394            return value;
395        }
396    }
397    product / c
398}
399
400/// `u - log1p(u)` without cancellation around zero.
401#[inline]
402fn gamma_unit_deviance_near_one(u: f64) -> f64 {
403    if u.abs() > 0.125 {
404        return u - u.ln_1p();
405    }
406    let mut power = u * u;
407    let mut sum = 0.5 * power;
408    for degree in 3..=32 {
409        power *= u;
410        let term = power / f64::from(degree);
411        let next = if degree % 2 == 0 {
412            sum + term
413        } else {
414            sum - term
415        };
416        if next == sum {
417            break;
418        }
419        sum = next;
420    }
421    sum
422}
423
424/// `(1+u)log1p(u)-u` without cancellation around zero.
425#[inline]
426fn poisson_unit_deviance_near_one(u: f64) -> f64 {
427    if u.abs() > 0.125 {
428        return (1.0 + u) * u.ln_1p() - u;
429    }
430    let mut power = u * u;
431    let mut sum = 0.5 * power;
432    for degree in 3..=32 {
433        power *= u;
434        let coefficient =
435            if degree % 2 == 0 { 1.0 } else { -1.0 } / (f64::from(degree) * f64::from(degree - 1));
436        let next = sum + coefficient * power;
437        if next == sum {
438            break;
439        }
440        sum = next;
441    }
442    sum
443}
444
445#[inline]
446fn row_gaussian_identity(
447    row: usize,
448    input: RowInput,
449    mode: CurvatureMode,
450) -> Result<RowOutput, EstimationError> {
451    finite_eta("standard identity inverse link", input.eta)?;
452    let w = prior_weight(row, input)?;
453    let mu = input.eta;
454    if w > 0.0 && !input.y.is_finite() {
455        return Err(row_error(row, "Gaussian response", input.eta, input.y));
456    }
457    let resid = input.y - mu;
458    let (grad_eta, dev) = if w == 0.0 {
459        (0.0, 0.0)
460    } else {
461        (w * resid, w * resid * resid)
462    };
463    let w_hessian = select_w_hessian(mode, w, 0.0);
464    certify_output(
465        row,
466        input.eta,
467        RowOutput {
468            mu,
469            grad_eta,
470            w_fisher: w,
471            w_hessian,
472            w_solver: w_hessian,
473            deviance: dev,
474        },
475    )
476}
477
478#[inline]
479fn row_poisson_log(
480    row: usize,
481    input: RowInput,
482    mode: CurvatureMode,
483) -> Result<RowOutput, EstimationError> {
484    let mu = crate::mixture_link::log_link_solver_exp(input.eta)?;
485    let w_prior = prior_weight(row, input)?;
486    if w_prior > 0.0 && !(input.y.is_finite() && input.y >= 0.0) {
487        return Err(row_error(row, "Poisson response", input.eta, input.y));
488    }
489    if w_prior == 0.0 {
490        return certify_output(
491            row,
492            input.eta,
493            RowOutput {
494                mu,
495                ..RowOutput::default()
496            },
497        );
498    }
499    let w_fisher = w_prior * mu;
500    if !(w_fisher.is_finite() && w_fisher > 0.0) {
501        return Err(row_error(row, "Poisson Fisher weight", input.eta, w_fisher));
502    }
503    let grad_eta = w_prior * (input.y - mu);
504    let u = (input.y - mu) / mu;
505    let dev_base = if input.y == 0.0 {
506        w_fisher
507    } else {
508        // Accurate around saturation. In either far tail this dimensionless
509        // ratio can become non-finite before multiplication by a tiny weight;
510        // only then switch to the algebraically identical absolute-coordinate
511        // expression, whose products are balanced independently.
512        let scaled_unit = w_fisher * poisson_unit_deviance_near_one(u);
513        if scaled_unit.is_finite() && scaled_unit >= 0.0 {
514            scaled_unit
515        } else {
516            let weighted_y = positive_mul_div(w_fisher, input.y, mu);
517            weighted_y * (input.y.ln() - input.eta - 1.0) + w_fisher
518        }
519    };
520    let dev = 2.0 * dev_base;
521    let w_hessian = select_w_hessian(mode, w_fisher, 0.0);
522    certify_output(
523        row,
524        input.eta,
525        RowOutput {
526            mu,
527            grad_eta,
528            w_fisher,
529            w_hessian,
530            w_solver: w_hessian,
531            deviance: dev,
532        },
533    )
534}
535
536#[inline]
537fn row_gamma_log(
538    row: usize,
539    input: RowInput,
540    mode: CurvatureMode,
541    shape: f64,
542) -> Result<RowOutput, EstimationError> {
543    let mu = crate::mixture_link::log_link_solver_exp(input.eta)?;
544    if !(shape.is_finite() && shape > 0.0) {
545        return Err(row_error(row, "Gamma shape", input.eta, shape));
546    }
547    let w_prior = prior_weight(row, input)?;
548    if w_prior > 0.0 && !(input.y.is_finite() && input.y > 0.0) {
549        return Err(row_error(row, "Gamma response", input.eta, input.y));
550    }
551    if w_prior == 0.0 {
552        return certify_output(
553            row,
554            input.eta,
555            RowOutput {
556                mu,
557                ..RowOutput::default()
558            },
559        );
560    }
561    let w_fisher = w_prior * shape;
562    if !(w_fisher.is_finite() && w_fisher > 0.0) {
563        return Err(row_error(row, "Gamma Fisher weight", input.eta, w_fisher));
564    }
565    let observed_ratio = match mode {
566        CurvatureMode::Fisher => None,
567        CurvatureMode::Observed => {
568            // Ratio-first: at `y == mu` the observed weight must reproduce the
569            // Fisher weight EXACTLY (`y/mu` is bit-for-bit 1.0), which the
570            // product-first reordering destroys by one ulp. The reordering
571            // fallback remains for tails where the direct form is not
572            // representable.
573            let direct = w_fisher * (input.y / mu);
574            let weighted_ratio = if direct.is_finite() && direct > 0.0 {
575                direct
576            } else {
577                positive_mul_div(w_fisher, input.y, mu)
578            };
579            if !(weighted_ratio.is_finite() && weighted_ratio > 0.0) {
580                return Err(row_error(
581                    row,
582                    "Gamma observed Hessian weight",
583                    input.eta,
584                    weighted_ratio,
585                ));
586            }
587            Some(weighted_ratio)
588        }
589    };
590    let w_hessian = observed_ratio.unwrap_or(w_fisher);
591    if !w_hessian.is_finite() {
592        return Err(row_error(
593            row,
594            "Gamma observed Hessian weight",
595            input.eta,
596            w_hessian,
597        ));
598    }
599    let u = (input.y - mu) / mu;
600    // `u` rounds to exactly -1 when y/mu is a representable but very small
601    // ratio, and the dimensionless expression can overflow for the opposite
602    // tail. Preserve the local-series path whenever it succeeds, then use the
603    // weighted absolute-coordinate identity for those two tail cases.
604    let scaled_unit = w_fisher * gamma_unit_deviance_near_one(u);
605    let need_weighted_ratio = !u.is_finite() || !(scaled_unit.is_finite() && scaled_unit >= 0.0);
606    let weighted_ratio = if need_weighted_ratio {
607        observed_ratio.unwrap_or_else(|| positive_mul_div(w_fisher, input.y, mu))
608    } else {
609        0.0
610    };
611    let grad_eta = if u.is_finite() {
612        w_fisher * u
613    } else {
614        weighted_ratio - w_fisher
615    };
616    let dev_base = if scaled_unit.is_finite() && scaled_unit >= 0.0 {
617        scaled_unit
618    } else {
619        weighted_ratio - w_fisher * (1.0 + input.y.ln() - input.eta)
620    };
621    let dev = 2.0 * dev_base;
622    certify_output(
623        row,
624        input.eta,
625        RowOutput {
626            mu,
627            grad_eta,
628            w_fisher,
629            w_hessian,
630            w_solver: w_hessian,
631            deviance: dev,
632        },
633    )
634}
635
636#[inline]
637fn bernoulli_response(row: usize, input: RowInput, w: f64) -> Result<(), EstimationError> {
638    if w == 0.0 || (input.y.is_finite() && (0.0..=1.0).contains(&input.y)) {
639        Ok(())
640    } else {
641        Err(row_error(row, "binomial response", input.eta, input.y))
642    }
643}
644
645#[inline]
646fn row_bernoulli_logit(
647    row: usize,
648    input: RowInput,
649    mode: CurvatureMode,
650) -> Result<RowOutput, EstimationError> {
651    finite_eta("standard logit inverse link", input.eta)?;
652    let w_prior = prior_weight(row, input)?;
653    bernoulli_response(row, input, w_prior)?;
654    let tail = (-input.eta.abs()).exp();
655    let denom = 1.0 + tail;
656    let (mu, residual) = if input.eta >= 0.0 {
657        let one_minus_mu = tail / denom;
658        let residual = if input.y == 1.0 {
659            one_minus_mu
660        } else {
661            (input.y - 1.0) + one_minus_mu
662        };
663        (1.0 / denom, residual)
664    } else {
665        let mu = tail / denom;
666        (mu, input.y - mu)
667    };
668    let dmu_deta = tail / (denom * denom);
669    if !(dmu_deta.is_finite() && dmu_deta > 0.0) {
670        return Err(row_error(
671            row,
672            "canonical-logit inverse-link jet",
673            input.eta,
674            dmu_deta,
675        ));
676    }
677    if w_prior == 0.0 {
678        return certify_output(
679            row,
680            input.eta,
681            RowOutput {
682                mu,
683                ..RowOutput::default()
684            },
685        );
686    }
687    let w_fisher = w_prior * dmu_deta;
688    if !(w_fisher.is_finite() && w_fisher > 0.0) {
689        return Err(row_error(row, "logit Fisher weight", input.eta, w_fisher));
690    }
691    let grad_eta = w_prior * residual;
692    let dev = bernoulli_logit_deviance(input.y, input.eta, w_prior);
693    let w_hessian = select_w_hessian(mode, w_fisher, 0.0);
694    certify_output(
695        row,
696        input.eta,
697        RowOutput {
698            mu,
699            grad_eta,
700            w_fisher,
701            w_hessian,
702            w_solver: w_hessian,
703            deviance: dev,
704        },
705    )
706}
707
708#[inline]
709fn row_bernoulli_probit(
710    row: usize,
711    input: RowInput,
712    mode: CurvatureMode,
713) -> Result<RowOutput, EstimationError> {
714    finite_eta("standard probit inverse link", input.eta)?;
715    let d1 = standard_normal_pdf(input.eta);
716    row_bernoulli_noncanonical(
717        row,
718        input,
719        mode,
720        standard_normal_cdf(input.eta),
721        d1,
722        -input.eta * d1,
723    )
724}
725
726#[inline]
727fn row_bernoulli_cloglog(
728    row: usize,
729    input: RowInput,
730    mode: CurvatureMode,
731) -> Result<RowOutput, EstimationError> {
732    finite_eta("standard complementary-log-log inverse link", input.eta)?;
733    let inner = input.eta.exp();
734    let mu = -(-inner).exp_m1();
735    let complement = (-inner).exp();
736    let d1 = inner * complement;
737    row_bernoulli_noncanonical(row, input, mode, mu, d1, d1 * (1.0 - inner))
738}
739
740#[inline]
741fn row_bernoulli_noncanonical(
742    row: usize,
743    input: RowInput,
744    mode: CurvatureMode,
745    mu: f64,
746    d1: f64,
747    d2: f64,
748) -> Result<RowOutput, EstimationError> {
749    let w_prior = prior_weight(row, input)?;
750    bernoulli_response(row, input, w_prior)?;
751    if !(mu.is_finite() && mu > 0.0 && mu < 1.0 && d1.is_finite() && d1 > 0.0 && d2.is_finite()) {
752        return Err(row_error(row, "inverse-link jet", input.eta, mu));
753    }
754    if w_prior == 0.0 {
755        return certify_output(
756            row,
757            input.eta,
758            RowOutput {
759                mu,
760                ..RowOutput::default()
761            },
762        );
763    }
764    let v = mu * (1.0 - mu);
765    let fisher_per_prior = d1 * d1 / v;
766    let w_fisher = w_prior * fisher_per_prior;
767    if !(v.is_finite()
768        && v > 0.0
769        && fisher_per_prior.is_finite()
770        && fisher_per_prior > 0.0
771        && w_fisher.is_finite()
772        && w_fisher > 0.0)
773    {
774        return Err(row_error(
775            row,
776            "Bernoulli Fisher weight",
777            input.eta,
778            w_fisher,
779        ));
780    }
781    let resid = input.y - mu;
782    let grad_eta = w_prior * resid * d1 / v;
783    let bracket = d2 / v - d1 * d1 * (1.0 - 2.0 * mu) / (v * v);
784    // -d²ℓ/dη² = W_F - prior·(y-μ)·d(h'/V)/dη.
785    let observed_correction = -w_prior * resid * bracket;
786    let w_hessian = select_w_hessian(mode, w_fisher, observed_correction);
787    if !w_hessian.is_finite() {
788        return Err(row_error(
789            row,
790            "Bernoulli observed Hessian weight",
791            input.eta,
792            w_hessian,
793        ));
794    }
795    let dev = bernoulli_deviance(input.y, mu, w_prior);
796    certify_output(
797        row,
798        input.eta,
799        RowOutput {
800            mu,
801            grad_eta,
802            w_fisher,
803            w_hessian,
804            w_solver: w_hessian,
805            deviance: dev,
806        },
807    )
808}
809
810#[inline]
811fn softplus(x: f64) -> f64 {
812    x.max(0.0) + (-x.abs()).exp().ln_1p()
813}
814
815#[inline]
816fn expm1_minus_x(x: f64) -> f64 {
817    if x.abs() > 0.5 {
818        return x.exp_m1() - x;
819    }
820    let mut term = 0.5 * x * x;
821    let mut sum = term;
822    let mut degree = 2.0;
823    loop {
824        degree += 1.0;
825        term *= x / degree;
826        let next = sum + term;
827        if next == sum {
828            return next;
829        }
830        sum = next;
831    }
832}
833
834#[inline]
835fn log1p_minus_x(x: f64) -> f64 {
836    if x.abs() > 0.5 {
837        return x.ln_1p() - x;
838    }
839    let mut power = x * x;
840    let mut sign = -1.0;
841    let mut degree = 2.0;
842    let mut sum = sign * power / degree;
843    loop {
844        power *= x;
845        sign = -sign;
846        degree += 1.0;
847        let next = sum + sign * power / degree;
848        if next == sum {
849            return next;
850        }
851        sum = next;
852    }
853}
854
855#[inline]
856fn logistic(x: f64) -> f64 {
857    if x >= 0.0 {
858        1.0 / (1.0 + (-x).exp())
859    } else {
860        let e = x.exp();
861        e / (1.0 + e)
862    }
863}
864
865/// `KL(sigmoid(a) || sigmoid(b))` without subtracting entropy from cross
866/// entropy. The local branch evaluates only second-order remainders.
867#[inline]
868fn bernoulli_kl_from_logits(a: f64, b: f64) -> f64 {
869    if a == b {
870        return 0.0;
871    }
872    let h = b - a;
873    if h.abs() <= 0.5 {
874        let (p, local_h) = if a <= 0.0 {
875            (logistic(a), h)
876        } else {
877            (logistic(-a), -h)
878        };
879        let em1 = local_h.exp_m1();
880        let x = p * em1;
881        return log1p_minus_x(x) + p * expm1_minus_x(local_h);
882    }
883    if a <= 0.0 {
884        let p = logistic(a);
885        p * (a - b) + softplus(b) - softplus(a)
886    } else {
887        let q = logistic(-a);
888        q * (b - a) + softplus(-b) - softplus(-a)
889    }
890}
891
892/// Accurate Poisson deviance cell `x log(x/m) + m - x`. The local series is
893/// used only when its contraction factor is small, so the loop is short and
894/// deterministic on both CPU and GPU.
895#[inline]
896fn bd0(x: f64, m: f64) -> f64 {
897    if x == 0.0 {
898        return m;
899    }
900    if x == m {
901        return 0.0;
902    }
903    let hi = x.max(m);
904    let lo = x.min(m);
905    if (x - m).abs() / hi < 0.2 {
906        let v = ((x - m) / hi) / (1.0 + lo / hi);
907        let mut sum = (x - m) * v;
908        let mut term = 2.0 * x * v;
909        let v2 = v * v;
910        let mut denominator = 3.0;
911        loop {
912            term *= v2;
913            let next = sum + term / denominator;
914            if next == sum {
915                return next;
916            }
917            sum = next;
918            denominator += 2.0;
919        }
920    }
921    x * (x.ln() - m.ln()) + (m - x)
922}
923
924#[inline]
925fn bernoulli_logit_deviance(y: f64, eta: f64, w: f64) -> f64 {
926    let unit = if y == 0.0 {
927        softplus(eta)
928    } else if y == 1.0 {
929        softplus(-eta)
930    } else {
931        let response_logit = y.ln() - (-y).ln_1p();
932        bernoulli_kl_from_logits(response_logit, eta)
933    };
934    2.0 * w * unit
935}
936
937#[inline]
938fn bernoulli_deviance(y: f64, mu: f64, w: f64) -> f64 {
939    2.0 * w * (bd0(y, mu) + bd0(1.0 - y, 1.0 - mu))
940}
941
942/// Stable Φ(x) using the complementary error function with the same identity
943/// `erfc(-x/√2)/2 = Φ(x)` used by libstd. Keeps mass at the tails accurate.
944#[inline]
945fn standard_normal_cdf(x: f64) -> f64 {
946    0.5 * gam_gpu::numerics_host::erfc(-x * std::f64::consts::FRAC_1_SQRT_2)
947}
948
949#[inline]
950fn standard_normal_pdf(x: f64) -> f64 {
951    const COEFF: f64 = 0.398_942_280_401_432_7; // 1 / sqrt(2π)
952    COEFF * (-0.5 * x * x).exp()
953}
954
955// ────────────────────────────────────────────────────────────────────────
956// CUDA host harness
957// ────────────────────────────────────────────────────────────────────────
958
959/// Process-wide cache of compiled per-family modules.
960#[must_use]
961pub struct PirlsRowBackend {
962    #[cfg(target_os = "linux")]
963    inner: PirlsRowBackendLinux,
964}
965
966#[cfg(target_os = "linux")]
967struct PirlsRowBackendLinux {
968    ctx: Arc<CudaContext>,
969    modules: Mutex<std::collections::HashMap<ModuleKey, Arc<CudaModule>>>,
970    /// Stage 6: separate cache for JIT-compiled custom-family modules
971    /// keyed by `(spec_id, curvature)`. Distinct JIT specs in the same
972    /// process get distinct cached modules.
973    jit_modules: Mutex<std::collections::HashMap<JitKey, Arc<CudaModule>>>,
974}
975
976/// Distinguishes the three kernel modes in the per-process module cache.
977#[cfg(target_os = "linux")]
978#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
979enum KernelMode {
980    /// Full final-row kernel (mu, grad_eta, w_hessian, w_solver, deviance,
981    /// status).
982    FinalRow,
983    /// Solve-row kernel (4 outputs: grad_eta, w_solver, deviance, status).
984    SolveRow,
985    /// Alpha-ladder kernel (2 per-alpha outputs: objective[], status[]).
986    AlphaLadder,
987}
988
989#[cfg(target_os = "linux")]
990#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
991struct ModuleKey {
992    family: PirlsRowFamily,
993    curvature: CurvatureMode,
994    mode: KernelMode,
995}
996
997impl PirlsRowBackend {
998    pub const fn compiled() -> bool {
999        cfg!(target_os = "linux")
1000    }
1001
1002    pub fn probe() -> Result<&'static Self, GpuError> {
1003        static BACKEND: OnceLock<Result<PirlsRowBackend, GpuError>> = OnceLock::new();
1004        BACKEND
1005            .get_or_init(|| {
1006                #[cfg(target_os = "linux")]
1007                {
1008                    Self::probe_linux()
1009                }
1010                #[cfg(not(target_os = "linux"))]
1011                {
1012                    Err(GpuError::DriverLibraryUnavailable {
1013                        reason: "pirls_row GPU backend is Linux-only".to_string(),
1014                    })
1015                }
1016            })
1017            .as_ref()
1018            .map_err(GpuError::clone)
1019    }
1020
1021    #[cfg(target_os = "linux")]
1022    fn probe_linux() -> Result<Self, GpuError> {
1023        let parts = gam_gpu::backend_probe::probe_cuda_backend("pirls_row")?;
1024        Ok(Self {
1025            inner: PirlsRowBackendLinux {
1026                ctx: parts.ctx,
1027                modules: Mutex::new(std::collections::HashMap::new()),
1028                jit_modules: Mutex::new(std::collections::HashMap::new()),
1029            },
1030        })
1031    }
1032
1033    /// Compile (or fetch from cache) the kernel module for `(family, curvature)`
1034    /// in the given [`KernelMode`]. This is the single source of truth behind
1035    /// [`module_for`], [`module_for_solve`], and [`module_for_ladder`]; the only
1036    /// per-mode variation is which CUDA source generator is used (selected by
1037    /// `mode`) and the error label `label` woven into compile/load diagnostics.
1038    #[cfg(target_os = "linux")]
1039    fn module_for_kind(
1040        &self,
1041        family: PirlsRowFamily,
1042        curvature: CurvatureMode,
1043        mode: KernelMode,
1044        label: &str,
1045    ) -> Result<Arc<CudaModule>, GpuError> {
1046        let key = ModuleKey {
1047            family,
1048            curvature,
1049            mode,
1050        };
1051        if let Some(existing) = self
1052            .inner
1053            .modules
1054            .lock()
1055            .gpu_ctx_with(|err| format!("pirls_row {label}module cache mutex poisoned: {err}"))?
1056            .get(&key)
1057        {
1058            return Ok(existing.clone());
1059        }
1060        let source = match mode {
1061            KernelMode::FinalRow => cuda_source_for(family, curvature),
1062            KernelMode::SolveRow => solve_row_source_for(family, curvature),
1063            KernelMode::AlphaLadder => ladder_source_for(family, curvature),
1064        };
1065        // #1551: route through the device-arch-pinned compile — this kernel uses
1066        // `atomicAdd(double*, double)` (objective_out), which NVRTC rejects under
1067        // its default sub-sm_60 arch, silently disabling the device PIRLS path.
1068        let ptx = gam_gpu::device_cache::compile_ptx_arch(&source).gpu_ctx_with(|err| {
1069            format!(
1070                "pirls_row {label}NVRTC compile failed for {family}/{curv}: {err}",
1071                family = family.as_str(),
1072                curv = curvature.as_str(),
1073            )
1074        })?;
1075        let module = self
1076            .inner
1077            .ctx
1078            .load_module(ptx)
1079            .gpu_ctx_with(|err| format!("pirls_row {label}module load failed: {err}"))?;
1080        self.inner
1081            .modules
1082            .lock()
1083            .gpu_ctx_with(|err| format!("pirls_row {label}module cache mutex poisoned: {err}"))?
1084            .insert(key, module.clone());
1085        Ok(module)
1086    }
1087
1088    /// Compile (or fetch from cache) the **final-row** kernel module for
1089    /// `(family, curvature)`. Writes the full production row surface.
1090    #[cfg(target_os = "linux")]
1091    pub fn module_for(
1092        &self,
1093        family: PirlsRowFamily,
1094        curvature: CurvatureMode,
1095    ) -> Result<Arc<CudaModule>, GpuError> {
1096        self.module_for_kind(family, curvature, KernelMode::FinalRow, "")
1097    }
1098
1099    /// Compile (or fetch from cache) the **solve-row** kernel module for
1100    /// `(family, curvature)`. Writes only `grad_eta`, `w_solver`, `deviance`,
1101    /// `status` — used on every hot Newton iteration.
1102    #[cfg(target_os = "linux")]
1103    pub fn module_for_solve(
1104        &self,
1105        family: PirlsRowFamily,
1106        curvature: CurvatureMode,
1107    ) -> Result<Arc<CudaModule>, GpuError> {
1108        self.module_for_kind(family, curvature, KernelMode::SolveRow, "solve ")
1109    }
1110
1111    /// Compile (or fetch from cache) the **alpha-ladder** kernel module for
1112    /// `(family, curvature)`. Evaluates all [`ALPHA_LADDER_LEN`] step sizes in
1113    /// a single launch, accumulating `objective[]` and `status[]` per alpha slot.
1114    #[cfg(target_os = "linux")]
1115    pub fn module_for_ladder(
1116        &self,
1117        family: PirlsRowFamily,
1118        curvature: CurvatureMode,
1119    ) -> Result<Arc<CudaModule>, GpuError> {
1120        self.module_for_kind(family, curvature, KernelMode::AlphaLadder, "ladder ")
1121    }
1122
1123    /// Stage 6: JIT-compile and cache a custom-family row module.
1124    ///
1125    /// The kernel name is `pirls_row_jit_{spec.spec_id}` so multiple
1126    /// distinct JIT specs in the same process get distinct cached
1127    /// modules. The cache key is `(spec_id, curvature)` which mirrors
1128    /// the built-in `(family, curvature)` cache and reuses the same
1129    /// HashMap-of-`Arc<CudaModule>` (but with a synthetic `ModuleKey`
1130    /// derived from the spec_id).
1131    ///
1132    /// Note: a fresh `(spec_id, curvature)` recompiles via NVRTC the
1133    /// first time; subsequent fits in the same process hit the cache.
1134    /// Spec changes (different body) must use a different `spec_id` so
1135    /// that the cache does NOT return a stale module.
1136    #[cfg(target_os = "linux")]
1137    pub fn module_for_jit(
1138        &self,
1139        spec: &JitFamilySpec,
1140        curvature: CurvatureMode,
1141    ) -> Result<Arc<CudaModule>, GpuError> {
1142        // Reuse the built-in ModuleKey by mapping `spec_id` to a
1143        // synthetic family slot. We piggy-back on the cache by keying
1144        // off a hashed family enum value won't fit cleanly; instead
1145        // use a separate JIT cache HashMap.
1146        let key = JitKey {
1147            spec_id: spec.spec_id,
1148            curvature,
1149        };
1150        if let Some(existing) = self
1151            .inner
1152            .jit_modules
1153            .lock()
1154            .gpu_ctx("pirls_row jit cache poisoned")?
1155            .get(&key)
1156        {
1157            return Ok(existing.clone());
1158        }
1159        let source = spec.cuda_source(curvature);
1160        // #1551: device-arch-pinned compile (double-atomic objective_out kernel).
1161        let ptx = gam_gpu::device_cache::compile_ptx_arch(&source).gpu_ctx_with(|err| {
1162            format!(
1163                "pirls_row JIT NVRTC compile failed for spec_id={} curvature={}: {err}",
1164                spec.spec_id,
1165                curvature.as_str(),
1166            )
1167        })?;
1168        let module = self
1169            .inner
1170            .ctx
1171            .load_module(ptx)
1172            .gpu_ctx("pirls_row JIT module load failed")?;
1173        self.inner
1174            .jit_modules
1175            .lock()
1176            .gpu_ctx("pirls_row jit cache poisoned (insert)")?
1177            .insert(key, module.clone());
1178        Ok(module)
1179    }
1180}
1181
1182/// Stage 6 cache key for JIT-compiled family modules.
1183#[cfg(target_os = "linux")]
1184#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
1185struct JitKey {
1186    spec_id: u64,
1187    curvature: CurvatureMode,
1188}
1189
1190/// Stage 6 custom-family JIT specification.
1191///
1192/// Two levels per the charter:
1193/// - **Level A** (`JitFamilySpec::glm`): provide a `(family, link)` enum
1194///   value plus the Gamma shape constant. The generator emits the
1195///   matching built-in row body, identical to the cached built-in
1196///   kernel — useful for end-to-end JIT path validation against the
1197///   built-in cache.
1198/// - **Level B** (`JitFamilySpec::raw`): provide raw CUDA source for
1199///   the row body. The body must define the same per-row locals the
1200///   kernel shell expects: `mu`, `grad_eta`, `w_hessian`, `w_solver`,
1201///   `dev`, and update `status`. The shell
1202///   wraps it in the canonical
1203///   `extern "C" __global__ void pirls_row_jit_{spec_id}(...)`
1204///   signature that [`launch_row_reweight_on_stream`] expects.
1205#[derive(Clone, Debug)]
1206pub struct JitFamilySpec {
1207    /// Process-unique identifier for this spec; the module cache uses
1208    /// it as a key so callers must reuse the same `spec_id` for the
1209    /// same body and pick a new one whenever the body changes.
1210    pub spec_id: u64,
1211    /// CUDA body source. Must read from `eta_i`, `y_i`, `wp`, set
1212    /// `status`, and assign to `mu`, `grad_eta`, `w_hessian`, `w_solver`,
1213    /// and `dev`. See `common_device_prolog` for
1214    /// the available helpers.
1215    pub body: String,
1216}
1217
1218impl JitFamilySpec {
1219    /// Level A: build a spec from a built-in `(family, curvature)`
1220    /// pair. The generator reuses the same per-family body as the
1221    /// built-in cached kernel — useful to validate the JIT pipeline
1222    /// end-to-end against the built-in numerical reference.
1223    #[cfg(target_os = "linux")]
1224    pub fn glm(
1225        spec_id: u64,
1226        family: PirlsRowFamily,
1227        curvature: CurvatureMode,
1228        gamma_shape: f64,
1229    ) -> Self {
1230        let mut body = match family {
1231            PirlsRowFamily::GaussianIdentity => gaussian_identity_body(curvature),
1232            PirlsRowFamily::PoissonLog => poisson_log_body(curvature),
1233            PirlsRowFamily::GammaLog => gamma_log_body(curvature),
1234            PirlsRowFamily::BernoulliLogit => bernoulli_logit_body(curvature),
1235            PirlsRowFamily::BernoulliProbit => bernoulli_probit_body(curvature),
1236            PirlsRowFamily::BernoulliCLogLog => bernoulli_cloglog_body(curvature),
1237        };
1238        if matches!(family, PirlsRowFamily::GammaLog) {
1239            body.insert_str(0, &format!("    const double shape = {gamma_shape:?};\n"));
1240        }
1241        Self { spec_id, body }
1242    }
1243
1244    /// Level B: build a spec from caller-supplied body source. The
1245    /// kernel shell wraps it; the body must define the required locals
1246    /// listed on [`JitFamilySpec`].
1247    pub fn raw(spec_id: u64, body: impl Into<String>) -> Self {
1248        Self {
1249            spec_id,
1250            body: body.into(),
1251        }
1252    }
1253
1254    /// The `extern "C"` kernel symbol the JIT-compiled module exposes.
1255    pub fn kernel_name(&self) -> String {
1256        format!("pirls_row_jit_{}", self.spec_id)
1257    }
1258
1259    /// Build the full CUDA source ready for NVRTC compilation. The
1260    /// shell + prolog match the built-in `cuda_source_for` so the JIT
1261    /// kernel ABI is bit-identical to the cached built-ins;
1262    /// [`launch_row_reweight_on_stream`] cannot tell the difference.
1263    #[cfg(target_os = "linux")]
1264    pub fn cuda_source(&self, curvature: CurvatureMode) -> String {
1265        let curvature_define = match curvature {
1266            CurvatureMode::Fisher => "#define PIRLS_CURVATURE_FISHER 1",
1267            CurvatureMode::Observed => "#define PIRLS_CURVATURE_OBSERVED 1",
1268        };
1269        let kernel_name = self.kernel_name();
1270        let body = &self.body;
1271        format!(
1272            r#"
1273{curvature_define}
1274{prolog}
1275
1276extern "C" __global__ void {kernel_name}(
1277    int            n,
1278    const double* __restrict__ eta,
1279    const double* __restrict__ y,
1280    const double* __restrict__ prior_w,
1281    double* __restrict__ mu_out,
1282    double* __restrict__ grad_eta_out,
1283    double* __restrict__ w_hessian_out,
1284    double* __restrict__ w_solver_out,
1285    double* __restrict__ deviance_out,
1286    unsigned int* __restrict__ status_out
1287) {{
1288    int i = blockIdx.x * blockDim.x + threadIdx.x;
1289    if (i >= n) return;
1290    unsigned int status = PIRLS_OK;
1291    double eta_i = eta[i];
1292    double y_i = y[i];
1293    double wp = prior_w[i];
1294{body}
1295    if (status == PIRLS_OK) {{
1296        mu_out[i] = mu;
1297        grad_eta_out[i] = grad_eta;
1298        w_hessian_out[i] = w_hessian;
1299        w_solver_out[i] = w_solver;
1300        deviance_out[i] = dev;
1301    }}
1302    status_out[i] = status;
1303}}
1304"#,
1305            prolog = common_device_prolog(),
1306        )
1307    }
1308}
1309
1310/// Device-resident per-row output buffers for the GPU row-reweight kernel.
1311///
1312/// **final-row mode**: the five production numerical fields plus status,
1313/// length `n`. Written
1314/// once at convergence by [`launch_row_reweight_on_stream`]. For the hot
1315/// inner-loop use [`SolveRowBuffers`]; for line-search use
1316/// [`AlphaLadderDevBuffers`].
1317#[cfg(target_os = "linux")]
1318pub struct RowOutputDevBuffers {
1319    pub mu: cudarc::driver::CudaSlice<f64>,
1320    pub grad_eta: cudarc::driver::CudaSlice<f64>,
1321    pub w_hessian: cudarc::driver::CudaSlice<f64>,
1322    pub w_solver: cudarc::driver::CudaSlice<f64>,
1323    pub deviance: cudarc::driver::CudaSlice<f64>,
1324    pub status: cudarc::driver::CudaSlice<u32>,
1325    pub n: usize,
1326}
1327
1328#[cfg(target_os = "linux")]
1329impl RowOutputDevBuffers {
1330    /// Allocate all production per-row output buffers (length `n`) on `stream`.
1331    pub fn allocate(stream: &Arc<cudarc::driver::CudaStream>, n: usize) -> Result<Self, GpuError> {
1332        let alloc_f64 = |label: &'static str| {
1333            stream
1334                .alloc_zeros::<f64>(n)
1335                .gpu_ctx_with(|err| format!("pirls_row alloc {label}: {err}"))
1336        };
1337        let alloc_u32 = |label: &'static str| {
1338            stream
1339                .alloc_zeros::<u32>(n)
1340                .gpu_ctx_with(|err| format!("pirls_row alloc {label}: {err}"))
1341        };
1342        Ok(Self {
1343            mu: alloc_f64("mu")?,
1344            grad_eta: alloc_f64("grad_eta")?,
1345            w_hessian: alloc_f64("w_hessian")?,
1346            w_solver: alloc_f64("w_solver")?,
1347            deviance: alloc_f64("deviance")?,
1348            status: alloc_u32("status")?,
1349            n,
1350        })
1351    }
1352}
1353
1354/// Device-resident per-row output buffers for the **solve-row** mode.
1355///
1356/// Allocates only the four fields the PIRLS solver reads on every Newton
1357/// iteration: `grad_eta` (score for Xᵀg RHS), `w_solver` (working weight
1358/// for XᵀWX assembly), `deviance` (per-row deviance for convergence check),
1359/// and `status` (one exact refusal code per row). Written
1360/// by [`launch_solve_row_on_stream`]; used instead of [`RowOutputDevBuffers`]
1361/// during the hot inner loop to reduce device memory and kernel store traffic.
1362#[cfg(target_os = "linux")]
1363pub struct SolveRowBuffers {
1364    /// ∂ℓ/∂η_i — score for Xᵀg RHS formation.
1365    pub grad_eta: cudarc::driver::CudaSlice<f64>,
1366    /// Stabilised Hessian weight — fed to XᵀWX assembly.
1367    pub w_solver: cudarc::driver::CudaSlice<f64>,
1368    /// Per-row deviance contribution — summed for convergence check.
1369    pub deviance: cudarc::driver::CudaSlice<f64>,
1370    /// Zero on success, otherwise the row's deterministic refusal code.
1371    pub status: cudarc::driver::CudaSlice<u32>,
1372    pub n: usize,
1373}
1374
1375#[cfg(target_os = "linux")]
1376impl SolveRowBuffers {
1377    /// Allocate the four solve-row output buffers (length `n`) on `stream`.
1378    pub fn allocate(stream: &Arc<cudarc::driver::CudaStream>, n: usize) -> Result<Self, GpuError> {
1379        let alloc_f64 = |label: &'static str| {
1380            stream
1381                .alloc_zeros::<f64>(n)
1382                .gpu_ctx_with(|err| format!("pirls_row solve alloc {label}: {err}"))
1383        };
1384        let alloc_u32 = |label: &'static str| {
1385            stream
1386                .alloc_zeros::<u32>(n)
1387                .gpu_ctx_with(|err| format!("pirls_row solve alloc {label}: {err}"))
1388        };
1389        Ok(Self {
1390            grad_eta: alloc_f64("grad_eta")?,
1391            w_solver: alloc_f64("w_solver")?,
1392            deviance: alloc_f64("deviance")?,
1393            status: alloc_u32("status")?,
1394            n,
1395        })
1396    }
1397}
1398
1399/// Number of alpha step sizes in the fused alpha ladder.
1400pub const ALPHA_LADDER_LEN: usize = 7;
1401
1402/// The fixed alpha step-size ladder: `[1, 0.5, 0.25, 0.125, 0.0625, 0.03125, 0.015625]`.
1403pub const ALPHA_LADDER: [f64; ALPHA_LADDER_LEN] =
1404    [1.0, 0.5, 0.25, 0.125, 0.0625, 0.03125, 0.015625];
1405
1406/// Device buffers for the fused alpha-ladder candidate-objective kernel.
1407///
1408/// **candidate-objective mode**: for each of the [`ALPHA_LADDER_LEN`] step
1409/// sizes α_k the kernel evaluates `η_trial_i = η_i + α_k · xδ_i`, computes
1410/// the per-row deviance, and atomically accumulates the sum into
1411/// `objective_dev[k]`. Each row writes its refusal code to
1412/// `status_dev[k * n + i]`, preserving deterministic row identity.
1413/// After a single `memcpy_dtoh` the host picks the first α that achieves
1414/// deviance descent — no per-α kernel launch, no full row-output write.
1415#[cfg(target_os = "linux")]
1416pub struct AlphaLadderDevBuffers {
1417    /// Device: summed deviance for each alpha step, length [`ALPHA_LADDER_LEN`].
1418    pub objective_dev: cudarc::driver::CudaSlice<f64>,
1419    /// Device: row refusal codes in alpha-major order, length
1420    /// `ALPHA_LADDER_LEN * n`.
1421    pub status_dev: cudarc::driver::CudaSlice<u32>,
1422    pub n: usize,
1423}
1424
1425#[cfg(target_os = "linux")]
1426impl AlphaLadderDevBuffers {
1427    /// Allocate the ladder device buffers on `stream`.
1428    pub fn allocate(stream: &Arc<cudarc::driver::CudaStream>, n: usize) -> Result<Self, GpuError> {
1429        let status_len = ALPHA_LADDER_LEN.checked_mul(n).ok_or_else(|| {
1430            gam_gpu::gpu_err!("pirls_row ladder status length overflows: {ALPHA_LADDER_LEN} * {n}")
1431        })?;
1432        Ok(Self {
1433            objective_dev: stream
1434                .alloc_zeros::<f64>(ALPHA_LADDER_LEN)
1435                .gpu_ctx_with(|err| format!("pirls_row ladder alloc objective: {err}"))?,
1436            status_dev: stream
1437                .alloc_zeros::<u32>(status_len)
1438                .gpu_ctx_with(|err| format!("pirls_row ladder alloc status: {err}"))?,
1439            n,
1440        })
1441    }
1442
1443    /// Zero all per-alpha accumulators in-place (call before each ladder launch).
1444    pub fn zero(&mut self, stream: &Arc<cudarc::driver::CudaStream>) -> Result<(), GpuError> {
1445        stream
1446            .memset_zeros(&mut self.objective_dev)
1447            .gpu_ctx_with(|err| format!("pirls_row ladder zero objective: {err}"))?;
1448        stream
1449            .memset_zeros(&mut self.status_dev)
1450            .gpu_ctx_with(|err| format!("pirls_row ladder zero status: {err}"))
1451    }
1452}
1453
1454/// Device-side row reweight launcher.
1455///
1456/// Resolves the cached per-family kernel from [`PirlsRowBackend::module_for`],
1457/// dispatches a 1D grid of `THREADS_PER_BLOCK = 256` threads across `n`
1458/// rows, and returns once the launch is enqueued on `stream`. The kernel
1459/// writes the per-row IRLS state into `out` in place; no host transfers.
1460///
1461/// The kernel's `extern "C"` signature is fixed at the top of `cuda_source_for`
1462/// (see `extern "C" __global__ void {kernel_name}(int n, …)` in this file).
1463/// `gamma_shape`: active Gamma dispersion shape (α > 0). Forwarded as a
1464/// scalar kernel argument only for `PirlsRowFamily::GammaLog`; all other
1465/// families compile a ten-argument kernel and ignore this value. Pass `1.0`
1466/// for non-Gamma fits.
1467#[cfg(target_os = "linux")]
1468pub fn launch_row_reweight_on_stream(
1469    backend: &PirlsRowBackend,
1470    family: PirlsRowFamily,
1471    curvature: CurvatureMode,
1472    gamma_shape: f64,
1473    stream: &Arc<cudarc::driver::CudaStream>,
1474    n: usize,
1475    eta_dev: &cudarc::driver::CudaSlice<f64>,
1476    y_dev: &cudarc::driver::CudaSlice<f64>,
1477    prior_w_dev: &cudarc::driver::CudaSlice<f64>,
1478    out: &mut RowOutputDevBuffers,
1479) -> Result<(), GpuError> {
1480    use cudarc::driver::{LaunchConfig, PushKernelArg};
1481    if out.n != n {
1482        gam_gpu::gpu_bail!("row reweight buffers shape {} mismatches n={n}", out.n);
1483    }
1484    let module = backend.module_for(family, curvature)?;
1485    let func = module
1486        .load_function(family.kernel_name())
1487        .gpu_ctx_with(|err| {
1488            format!(
1489                "row reweight load_function({}): {err}",
1490                family.kernel_name()
1491            )
1492        })?;
1493    const THREADS_PER_BLOCK: u32 = 256;
1494    let n_u32 = u32::try_from(n)
1495        .map_err(|_| gam_gpu::gpu_err!("n={n} exceeds u32 for row reweight grid sizing"))?;
1496    let grid_x = n_u32.div_ceil(THREADS_PER_BLOCK).max(1);
1497    let n_i32 = i32::try_from(n)
1498        .map_err(|_| gam_gpu::gpu_err!("n={n} exceeds i32 for row reweight kernel argument"))?;
1499    let cfg = LaunchConfig {
1500        grid_dim: (grid_x, 1, 1),
1501        block_dim: (THREADS_PER_BLOCK, 1, 1),
1502        shared_mem_bytes: 0,
1503    };
1504    let mut builder = stream.launch_builder(&func);
1505    builder.arg(&n_i32);
1506    builder.arg(eta_dev);
1507    builder.arg(y_dev);
1508    builder.arg(prior_w_dev);
1509    // GammaLog kernel has `double shape` before the output buffers.
1510    if matches!(family, PirlsRowFamily::GammaLog) {
1511        builder.arg(&gamma_shape);
1512    }
1513    builder.arg(&mut out.mu);
1514    builder.arg(&mut out.grad_eta);
1515    builder.arg(&mut out.w_hessian);
1516    builder.arg(&mut out.w_solver);
1517    builder.arg(&mut out.deviance);
1518    builder.arg(&mut out.status);
1519    // SAFETY: kernel signature for non-GammaLog is (n:i32, 3×const f64*,
1520    // 5×mut f64*, 1×mut u32*). GammaLog extends this with `double shape`
1521    // after `prior_w` and before the output buffers — see `cuda_source_for`.
1522    // Arg order/types match one-for-one. Output buffers were allocated with
1523    // `n` elements each (validated above); input buffers are caller-supplied
1524    // with length n. Grid covers all n rows; threads guard `if (i >= n) return`.
1525    unsafe { builder.launch(cfg) }
1526        .gpu_ctx_with(|err| format!("row reweight launch({}): {err}", family.kernel_name()))?;
1527    Ok(())
1528}
1529
1530
1531/// **solve-row** mode launcher.
1532///
1533/// Runs the per-family row math and writes only the four fields needed by the
1534/// PIRLS solver on each Newton iteration: `grad_eta`, `w_solver`, `deviance`,
1535/// `status`. The CUDA kernel is compiled from a specialised source
1536/// (`solve_row_source_for`) that skips the `mu` and `w_hessian` stores,
1537/// reducing both bandwidth and register
1538/// pressure relative to [`launch_row_reweight_on_stream`].
1539///
1540/// Call once per Newton step on the accepted η. At convergence, call
1541/// [`launch_row_reweight_on_stream`] (final-row mode) to populate the full
1542/// output surface before downloading.
1543///
1544/// `gamma_shape`: active Gamma dispersion shape (α > 0). Forwarded as a kernel
1545/// argument only for `PirlsRowFamily::GammaLog`. Pass `1.0` for non-Gamma fits.
1546#[cfg(target_os = "linux")]
1547pub fn launch_solve_row_on_stream(
1548    backend: &PirlsRowBackend,
1549    family: PirlsRowFamily,
1550    curvature: CurvatureMode,
1551    gamma_shape: f64,
1552    stream: &Arc<cudarc::driver::CudaStream>,
1553    n: usize,
1554    eta_dev: &cudarc::driver::CudaSlice<f64>,
1555    y_dev: &cudarc::driver::CudaSlice<f64>,
1556    prior_w_dev: &cudarc::driver::CudaSlice<f64>,
1557    out: &mut SolveRowBuffers,
1558) -> Result<(), GpuError> {
1559    use cudarc::driver::{LaunchConfig, PushKernelArg};
1560    if out.n != n {
1561        gam_gpu::gpu_bail!("solve-row buffers shape {} mismatches n={n}", out.n);
1562    }
1563    let module = backend.module_for_solve(family, curvature)?;
1564    let kernel_name = family.solve_kernel_name();
1565    let func = module
1566        .load_function(kernel_name)
1567        .gpu_ctx_with(|err| format!("solve-row load_function({kernel_name}): {err}"))?;
1568    const THREADS_PER_BLOCK: u32 = 256;
1569    let n_u32 = u32::try_from(n)
1570        .map_err(|_| gam_gpu::gpu_err!("n={n} exceeds u32 for solve-row grid sizing"))?;
1571    let grid_x = n_u32.div_ceil(THREADS_PER_BLOCK).max(1);
1572    let n_i32 = i32::try_from(n)
1573        .map_err(|_| gam_gpu::gpu_err!("n={n} exceeds i32 for solve-row kernel argument"))?;
1574    let cfg = LaunchConfig {
1575        grid_dim: (grid_x, 1, 1),
1576        block_dim: (THREADS_PER_BLOCK, 1, 1),
1577        shared_mem_bytes: 0,
1578    };
1579    let mut builder = stream.launch_builder(&func);
1580    builder.arg(&n_i32);
1581    builder.arg(eta_dev);
1582    builder.arg(y_dev);
1583    builder.arg(prior_w_dev);
1584    // GammaLog solve kernel has `double shape` before the output buffers.
1585    if matches!(family, PirlsRowFamily::GammaLog) {
1586        builder.arg(&gamma_shape);
1587    }
1588    builder.arg(&mut out.grad_eta);
1589    builder.arg(&mut out.w_solver);
1590    builder.arg(&mut out.deviance);
1591    builder.arg(&mut out.status);
1592    // SAFETY: solve_row_source_for emits for non-GammaLog:
1593    //   (int n, const f64* eta, const f64* y, const f64* prior_w,
1594    //    f64* grad_eta_out, f64* w_solver_out, f64* deviance_out, u32* status_out)
1595    // For GammaLog the signature inserts `double shape` after `prior_w`.
1596    // 4 outputs match the 4 SolveRowBuffers fields; grid covers all n rows with
1597    // per-thread guard `if (i >= n) return`.
1598    unsafe { builder.launch(cfg) }
1599        .gpu_ctx_with(|err| format!("solve-row launch({kernel_name}): {err}"))?;
1600    Ok(())
1601}
1602
1603/// **candidate-objective / fused alpha-ladder** launcher.
1604///
1605/// Evaluates `η_trial_i = η_i + α_k · xδ_i` for all `i ∈ [0,n)` and all
1606/// `k ∈ [0, ALPHA_LADDER_LEN)` simultaneously.  Each thread atomically
1607/// accumulates the per-row deviance into `out.objective_dev[k]` and
1608/// writes each refusal code to `out.status_dev[k * n + i]`.
1609///
1610/// The grid is `(row_blocks × ALPHA_LADDER_LEN)`: block index `bx / n_blocks`
1611/// selects the alpha slot, `bx % n_blocks` selects the row tile.
1612///
1613/// Caller must call [`AlphaLadderDevBuffers::zero`] before each launch, then
1614/// reduce the alpha-major status matrix with the deterministic smallest-row
1615/// reducer, then read the seven objectives plus seven row/code summaries.
1616#[cfg(target_os = "linux")]
1617pub fn launch_alpha_ladder_on_stream(
1618    backend: &PirlsRowBackend,
1619    family: PirlsRowFamily,
1620    curvature: CurvatureMode,
1621    gamma_shape: f64,
1622    stream: &Arc<cudarc::driver::CudaStream>,
1623    n: usize,
1624    eta_dev: &cudarc::driver::CudaSlice<f64>,
1625    xd_dev: &cudarc::driver::CudaSlice<f64>,
1626    y_dev: &cudarc::driver::CudaSlice<f64>,
1627    prior_w_dev: &cudarc::driver::CudaSlice<f64>,
1628    out: &mut AlphaLadderDevBuffers,
1629) -> Result<(), GpuError> {
1630    use cudarc::driver::{LaunchConfig, PushKernelArg};
1631    if out.n != n {
1632        gam_gpu::gpu_bail!("alpha-ladder buffers shape {} mismatches n={n}", out.n);
1633    }
1634    let module = backend.module_for_ladder(family, curvature)?;
1635    let kernel_name = family.ladder_kernel_name();
1636    let func = module
1637        .load_function(kernel_name)
1638        .gpu_ctx_with(|err| format!("alpha-ladder load_function({kernel_name}): {err}"))?;
1639    const THREADS_PER_BLOCK: u32 = 256;
1640    let n_u32 = u32::try_from(n)
1641        .map_err(|_| gam_gpu::gpu_err!("n={n} exceeds u32 for alpha-ladder grid sizing"))?;
1642    let row_blocks = n_u32.div_ceil(THREADS_PER_BLOCK).max(1);
1643    let n_i32 = i32::try_from(n)
1644        .map_err(|_| gam_gpu::gpu_err!("n={n} exceeds i32 for alpha-ladder kernel argument"))?;
1645    // Grid: x = row tile index (0..row_blocks), y = alpha index (0..ALPHA_LADDER_LEN).
1646    let cfg = LaunchConfig {
1647        grid_dim: (row_blocks, ALPHA_LADDER_LEN as u32, 1),
1648        block_dim: (THREADS_PER_BLOCK, 1, 1),
1649        shared_mem_bytes: 0,
1650    };
1651    let mut builder = stream.launch_builder(&func);
1652    builder.arg(&n_i32);
1653    builder.arg(eta_dev);
1654    builder.arg(xd_dev);
1655    builder.arg(y_dev);
1656    builder.arg(prior_w_dev);
1657    // GammaLog ladder kernel has `double shape` before the output buffers.
1658    if matches!(family, PirlsRowFamily::GammaLog) {
1659        builder.arg(&gamma_shape);
1660    }
1661    builder.arg(&mut out.objective_dev);
1662    builder.arg(&mut out.status_dev);
1663    // SAFETY: for non-GammaLog, ladder_source_for emits:
1664    //   (int n, const f64* eta, const f64* xd, const f64* y, const f64* prior_w,
1665    //    f64* objective_out, u32* status_out)
1666    // For GammaLog `double shape` is inserted after `prior_w`.
1667    // Grid is (row_blocks × ALPHA_LADDER_LEN); each thread reads alphas[] via
1668    // blockIdx.y, rows via blockIdx.x * blockDim.x + threadIdx.x (guarded by n).
1669    // Atomic double-precision add to objective_out[blockIdx.y]; each thread
1670    // owns status_out[blockIdx.y * n + i].
1671    unsafe { builder.launch(cfg) }
1672        .gpu_ctx_with(|err| format!("alpha-ladder launch({kernel_name}): {err}"))?;
1673    Ok(())
1674}
1675
1676// ────────────────────────────────────────────────────────────────────────
1677// CUDA sources (one per family / curvature pair)
1678// ────────────────────────────────────────────────────────────────────────
1679
1680/// Common device-side helpers shared across every family kernel.  The log-link
1681/// interval is injected from the same Rust constants used by the CPU oracle,
1682/// so the generated CUDA source cannot drift to a second domain policy.
1683#[cfg(target_os = "linux")]
1684fn common_device_prolog() -> String {
1685    // Brace-heavy CUDA source: keep it a plain raw string literal so every C
1686    // brace stays literal (no `format!` grammar to escape), and substitute the
1687    // two host solver bounds by unique sentinel token.
1688    r#"
1689// NVRTC math builtins: prototypes must carry an execution space. Newer
1690// NVRTC (CUDA 12.x JIT semantics) rejects unannotated declarations outright
1691// ("host functions are not allowed in JIT mode"), which failed every
1692// pirls_row kernel compile on real hardware while CPU-only CI stayed green
1693// (#2313 hardware sweep). `__device__` matches how the CUDA math library
1694// declares them; the definitions come from libdevice as before.
1695extern "C" {
1696    __device__ double exp(double);
1697    __device__ double log(double);
1698    __device__ double log1p(double);
1699    __device__ double expm1(double);
1700    __device__ double fabs(double);
1701    __device__ double erfc(double);
1702}
1703
1704static constexpr double PIRLS_LOG_ETA_MIN = __PIRLS_LOG_ETA_MIN__;
1705static constexpr double PIRLS_LOG_ETA_MAX = __PIRLS_LOG_ETA_MAX__;
1706
1707static constexpr unsigned int PIRLS_OK = 0u;
1708static constexpr unsigned int PIRLS_ETA_DOMAIN = 1u;
1709static constexpr unsigned int PIRLS_PRIOR_WEIGHT = 2u;
1710static constexpr unsigned int PIRLS_RESPONSE = 3u;
1711static constexpr unsigned int PIRLS_GAMMA_SHAPE = 4u;
1712static constexpr unsigned int PIRLS_INVERSE_LINK = 5u;
1713static constexpr unsigned int PIRLS_FISHER_WEIGHT = 6u;
1714static constexpr unsigned int PIRLS_OBSERVED_WEIGHT = 7u;
1715static constexpr unsigned int PIRLS_GRADIENT = 8u;
1716static constexpr unsigned int PIRLS_DEVIANCE = 9u;
1717static constexpr unsigned int PIRLS_FINAL_OUTPUT = 10u;
1718
1719__device__ __forceinline__ void pirls_refuse(unsigned int* status, unsigned int code) {
1720    if (*status == PIRLS_OK) *status = code;
1721}
1722
1723__device__ __forceinline__ bool pirls_log_eta_valid(double eta) {
1724    return eta >= PIRLS_LOG_ETA_MIN && eta <= PIRLS_LOG_ETA_MAX;
1725}
1726
1727__device__ __forceinline__ double softplus(double x) {
1728    return (x > 0.0 ? x : 0.0) + log1p(exp(-fabs(x)));
1729}
1730
1731__device__ __forceinline__ double expm1_minus_x(double x) {
1732    if (fabs(x) > 0.5) return expm1(x) - x;
1733    double term = 0.5 * x * x;
1734    double sum = term;
1735    double degree = 2.0;
1736    for (;;) {
1737        degree += 1.0;
1738        term *= x / degree;
1739        double next = sum + term;
1740        if (next == sum) return next;
1741        sum = next;
1742    }
1743}
1744
1745__device__ __forceinline__ double log1p_minus_x(double x) {
1746    if (fabs(x) > 0.5) return log1p(x) - x;
1747    double power = x * x;
1748    double sign = -1.0;
1749    double degree = 2.0;
1750    double sum = sign * power / degree;
1751    for (;;) {
1752        power *= x;
1753        sign = -sign;
1754        degree += 1.0;
1755        double next = sum + sign * power / degree;
1756        if (next == sum) return next;
1757        sum = next;
1758    }
1759}
1760
1761__device__ __forceinline__ double logistic(double x) {
1762    if (x >= 0.0) return 1.0 / (1.0 + exp(-x));
1763    double e = exp(x);
1764    return e / (1.0 + e);
1765}
1766
1767__device__ __forceinline__ double bernoulli_kl_from_logits(double a, double b) {
1768    if (a == b) return 0.0;
1769    double h = b - a;
1770    if (fabs(h) <= 0.5) {
1771        double p = a <= 0.0 ? logistic(a) : logistic(-a);
1772        double local_h = a <= 0.0 ? h : -h;
1773        double em1 = expm1(local_h);
1774        double x = p * em1;
1775        return log1p_minus_x(x) + p * expm1_minus_x(local_h);
1776    }
1777    if (a <= 0.0) {
1778        double p = logistic(a);
1779        return p * (a - b) + softplus(b) - softplus(a);
1780    }
1781    double q = logistic(-a);
1782    return q * (b - a) + softplus(-b) - softplus(-a);
1783}
1784
1785__device__ __forceinline__ double bd0(double x, double m) {
1786    if (x == 0.0) return m;
1787    if (x == m) return 0.0;
1788    double hi = x > m ? x : m;
1789    double lo = x < m ? x : m;
1790    if (fabs(x - m) / hi < 0.2) {
1791        double v = ((x - m) / hi) / (1.0 + lo / hi);
1792        double sum = (x - m) * v;
1793        double term = 2.0 * x * v;
1794        double v2 = v * v;
1795        double denominator = 3.0;
1796        for (;;) {
1797            term *= v2;
1798            double next = sum + term / denominator;
1799            if (next == sum) return next;
1800            sum = next;
1801            denominator += 2.0;
1802        }
1803    }
1804    return x * (log(x) - log(m)) + (m - x);
1805}
1806
1807__device__ __forceinline__ double bernoulli_deviance(double y, double mu, double w) {
1808    return 2.0 * w * (bd0(y, mu) + bd0(1.0 - y, 1.0 - mu));
1809}
1810
1811__device__ __forceinline__ double logit_deviance(double y, double eta, double w) {
1812    double unit;
1813    if (y == 0.0) unit = softplus(eta);
1814    else if (y == 1.0) unit = softplus(-eta);
1815    else {
1816        double response_logit = log(y) - log1p(-y);
1817        unit = bernoulli_kl_from_logits(response_logit, eta);
1818    }
1819    return 2.0 * w * unit;
1820}
1821
1822__device__ __forceinline__ double std_norm_cdf(double x) {
1823    return 0.5 * erfc(-x * 0.7071067811865475);
1824}
1825
1826__device__ __forceinline__ double std_norm_pdf(double x) {
1827    return 0.3989422804014327 * exp(-0.5 * x * x);
1828}
1829
1830__device__ __forceinline__ double positive_mul_div(double a, double b, double c) {
1831    double product = a * b;
1832    if (isfinite(product) && product > 0.0) {
1833        double value = product / c;
1834        if (isfinite(value) && value > 0.0) return value;
1835    }
1836    double quotient_a = a / c;
1837    if (isfinite(quotient_a) && quotient_a > 0.0) {
1838        double value = quotient_a * b;
1839        if (isfinite(value) && value > 0.0) return value;
1840    }
1841    double quotient_b = b / c;
1842    if (isfinite(quotient_b) && quotient_b > 0.0) {
1843        double value = quotient_b * a;
1844        if (isfinite(value) && value > 0.0) return value;
1845    }
1846    return product / c;
1847}
1848
1849__device__ __forceinline__ double gamma_unit_deviance_near_one(double u) {
1850    if (fabs(u) > 0.125) return u - log1p(u);
1851    double power = u * u;
1852    double sum = 0.5 * power;
1853    for (int degree = 3; degree <= 32; ++degree) {
1854        power *= u;
1855        double term = power / (double)degree;
1856        double next = sum + ((degree & 1) ? -term : term);
1857        if (next == sum) break;
1858        sum = next;
1859    }
1860    return sum;
1861}
1862
1863__device__ __forceinline__ double poisson_unit_deviance_near_one(double u) {
1864    if (fabs(u) > 0.125) return (1.0 + u) * log1p(u) - u;
1865    double power = u * u;
1866    double sum = 0.5 * power;
1867    for (int degree = 3; degree <= 32; ++degree) {
1868        power *= u;
1869        double coefficient = ((degree & 1) ? -1.0 : 1.0)
1870            / ((double)degree * (double)(degree - 1));
1871        double next = sum + coefficient * power;
1872        if (next == sum) break;
1873        sum = next;
1874    }
1875    return sum;
1876}
1877
1878__device__ __forceinline__ bool pirls_outputs_finite(
1879    double mu, double grad_eta, double w_fisher, double w_hessian,
1880    double w_solver, double dev
1881) {
1882    return isfinite(mu) && isfinite(grad_eta) && isfinite(w_fisher)
1883        && isfinite(w_hessian) && isfinite(w_solver) && isfinite(dev);
1884}
1885"#
1886    .replace(
1887        "__PIRLS_LOG_ETA_MIN__",
1888        &format!("{:?}", crate::mixture_link::LOG_LINK_SOLVER_ETA_MIN),
1889    )
1890    .replace(
1891        "__PIRLS_LOG_ETA_MAX__",
1892        &format!("{:?}", crate::mixture_link::LOG_LINK_SOLVER_ETA_MAX),
1893    )
1894}
1895
1896/// Build the per-family CUDA source. Each source defines exactly one entry
1897/// kernel (`family.kernel_name()`) reading the input arrays and writing the
1898/// output arrays defined by the [`RowOutput`] contract above.
1899///
1900/// The GammaLog kernel has an extra `double shape` parameter after `prior_w`
1901/// so the host can forward the active dispersion shape. All other families
1902/// use the standard ten-argument signature.
1903#[cfg(target_os = "linux")]
1904fn cuda_source_for(family: PirlsRowFamily, curvature: CurvatureMode) -> String {
1905    let body = match family {
1906        PirlsRowFamily::GaussianIdentity => gaussian_identity_body(curvature),
1907        PirlsRowFamily::PoissonLog => poisson_log_body(curvature),
1908        PirlsRowFamily::GammaLog => gamma_log_body(curvature),
1909        PirlsRowFamily::BernoulliLogit => bernoulli_logit_body(curvature),
1910        PirlsRowFamily::BernoulliProbit => bernoulli_probit_body(curvature),
1911        PirlsRowFamily::BernoulliCLogLog => bernoulli_cloglog_body(curvature),
1912    };
1913    let kernel_name = family.kernel_name();
1914    // Inject a curvature marker into the source so each `(family, curvature)`
1915    // pair compiles a distinct PTX module (cache key) and Stage 5 can branch
1916    // on `PIRLS_CURVATURE_OBSERVED` from inside each per-family body without
1917    // changing the host harness.
1918    let curvature_define = match curvature {
1919        CurvatureMode::Fisher => "#define PIRLS_CURVATURE_FISHER 1",
1920        CurvatureMode::Observed => "#define PIRLS_CURVATURE_OBSERVED 1",
1921    };
1922    // GammaLog receives the active shape as a scalar kernel argument so the
1923    // host can forward any positive shape without recompiling the PTX.
1924    let shape_param = if matches!(family, PirlsRowFamily::GammaLog) {
1925        "    double         shape,\n"
1926    } else {
1927        ""
1928    };
1929    format!(
1930        r#"
1931{curvature_define}
1932{prolog}
1933
1934extern "C" __global__ void {kernel_name}(
1935    int            n,
1936    const double* __restrict__ eta,
1937    const double* __restrict__ y,
1938    const double* __restrict__ prior_w,
1939{shape_param}    double* __restrict__ mu_out,
1940    double* __restrict__ grad_eta_out,
1941    double* __restrict__ w_hessian_out,
1942    double* __restrict__ w_solver_out,
1943    double* __restrict__ deviance_out,
1944    unsigned int* __restrict__ status_out
1945) {{
1946    int i = blockIdx.x * blockDim.x + threadIdx.x;
1947    if (i >= n) return;
1948    unsigned int status = PIRLS_OK;
1949    double eta_i = eta[i];
1950    double y_i = y[i];
1951    double wp = prior_w[i];
1952{body}
1953    if (status == PIRLS_OK) {{
1954        mu_out[i] = mu;
1955        grad_eta_out[i] = grad_eta;
1956        w_hessian_out[i] = w_hessian;
1957        w_solver_out[i] = w_solver;
1958        deviance_out[i] = dev;
1959    }}
1960    status_out[i] = status;
1961}}
1962"#,
1963        prolog = common_device_prolog(),
1964    )
1965}
1966
1967/// Emits a CUDA comment tag identifying the curvature mode the kernel was
1968/// compiled for. Each body builder prepends this so the body source actually
1969/// consumes the `curvature` argument and Stage 5 can keyed-extend the bodies
1970/// behind `#ifdef PIRLS_CURVATURE_OBSERVED`.
1971#[cfg(target_os = "linux")]
1972#[inline]
1973fn curvature_tag(curvature: CurvatureMode) -> &'static str {
1974    match curvature {
1975        CurvatureMode::Fisher => "    // curvature: fisher\n",
1976        CurvatureMode::Observed => "    // curvature: observed\n",
1977    }
1978}
1979
1980#[cfg(target_os = "linux")]
1981fn gaussian_identity_body(curvature: CurvatureMode) -> String {
1982    let tag = curvature_tag(curvature);
1983    format!(
1984        r#"{tag}    double mu = 0.0, grad_eta = 0.0, w_fisher = 0.0;
1985    double w_hessian = 0.0, w_solver = 0.0, dev = 0.0;
1986    if (!isfinite(eta_i)) pirls_refuse(&status, PIRLS_ETA_DOMAIN);
1987    if (status == PIRLS_OK && !(isfinite(wp) && wp >= 0.0))
1988        pirls_refuse(&status, PIRLS_PRIOR_WEIGHT);
1989    if (status == PIRLS_OK && wp > 0.0 && !isfinite(y_i))
1990        pirls_refuse(&status, PIRLS_RESPONSE);
1991    if (status == PIRLS_OK) {{
1992        mu = eta_i;
1993        w_fisher = wp;
1994        w_hessian = wp;
1995        w_solver = w_hessian;
1996        if (wp > 0.0) {{
1997            double resid = y_i - mu;
1998            grad_eta = wp * resid;
1999            dev = wp * resid * resid;
2000        }}
2001    }}
2002    if (status == PIRLS_OK && !pirls_outputs_finite(
2003            mu, grad_eta, w_fisher, w_hessian, w_solver, dev))
2004        pirls_refuse(&status, PIRLS_FINAL_OUTPUT);
2005"#
2006    )
2007}
2008
2009#[cfg(target_os = "linux")]
2010fn poisson_log_body(curvature: CurvatureMode) -> String {
2011    let tag = curvature_tag(curvature);
2012    format!(
2013        r#"{tag}    double mu = 0.0, grad_eta = 0.0, w_fisher = 0.0;
2014    double w_hessian = 0.0, w_solver = 0.0, dev = 0.0;
2015    if (!pirls_log_eta_valid(eta_i)) pirls_refuse(&status, PIRLS_ETA_DOMAIN);
2016    if (status == PIRLS_OK && !(isfinite(wp) && wp >= 0.0))
2017        pirls_refuse(&status, PIRLS_PRIOR_WEIGHT);
2018    if (status == PIRLS_OK && wp > 0.0 && !(isfinite(y_i) && y_i >= 0.0))
2019        pirls_refuse(&status, PIRLS_RESPONSE);
2020    if (status == PIRLS_OK) {{
2021        mu = exp(eta_i);
2022        if (!(isfinite(mu) && mu > 0.0)) pirls_refuse(&status, PIRLS_INVERSE_LINK);
2023    }}
2024    if (status == PIRLS_OK && wp > 0.0) {{
2025        w_fisher = wp * mu;
2026        if (!(isfinite(w_fisher) && w_fisher > 0.0))
2027            pirls_refuse(&status, PIRLS_FISHER_WEIGHT);
2028        if (status == PIRLS_OK) {{
2029            w_hessian = w_fisher;
2030            w_solver = w_hessian;
2031            grad_eta = wp * (y_i - mu);
2032            double u = (y_i - mu) / mu;
2033            double dev_base;
2034            if (y_i == 0.0) {{
2035                dev_base = w_fisher;
2036            }} else {{
2037                double scaled_unit = w_fisher * poisson_unit_deviance_near_one(u);
2038                if (isfinite(scaled_unit) && scaled_unit >= 0.0) {{
2039                    dev_base = scaled_unit;
2040                }} else {{
2041                    double weighted_y = positive_mul_div(w_fisher, y_i, mu);
2042                    dev_base = weighted_y * (log(y_i) - eta_i - 1.0) + w_fisher;
2043                }}
2044            }}
2045            if (!isfinite(grad_eta)) pirls_refuse(&status, PIRLS_GRADIENT);
2046            dev = 2.0 * dev_base;
2047            if (!isfinite(dev)) pirls_refuse(&status, PIRLS_DEVIANCE);
2048        }}
2049    }}
2050    if (status == PIRLS_OK && !pirls_outputs_finite(
2051            mu, grad_eta, w_fisher, w_hessian, w_solver, dev))
2052        pirls_refuse(&status, PIRLS_FINAL_OUTPUT);
2053"#
2054    )
2055}
2056
2057#[cfg(target_os = "linux")]
2058fn gamma_log_body(curvature: CurvatureMode) -> String {
2059    // `shape` is a kernel parameter (see `cuda_source_for`); the body reads it
2060    // directly. No local shadowing needed.
2061    let tag = curvature_tag(curvature);
2062    format!(
2063        r#"{tag}    double mu = 0.0, grad_eta = 0.0, w_fisher = 0.0;
2064    double w_hessian = 0.0, w_solver = 0.0, dev = 0.0;
2065    if (!pirls_log_eta_valid(eta_i)) pirls_refuse(&status, PIRLS_ETA_DOMAIN);
2066    if (status == PIRLS_OK && !(isfinite(shape) && shape > 0.0))
2067        pirls_refuse(&status, PIRLS_GAMMA_SHAPE);
2068    if (status == PIRLS_OK && !(isfinite(wp) && wp >= 0.0))
2069        pirls_refuse(&status, PIRLS_PRIOR_WEIGHT);
2070    if (status == PIRLS_OK && wp > 0.0 && !(isfinite(y_i) && y_i > 0.0))
2071        pirls_refuse(&status, PIRLS_RESPONSE);
2072    if (status == PIRLS_OK) {{
2073        mu = exp(eta_i);
2074        if (!(isfinite(mu) && mu > 0.0)) pirls_refuse(&status, PIRLS_INVERSE_LINK);
2075    }}
2076    if (status == PIRLS_OK && wp > 0.0) {{
2077        w_fisher = wp * shape;
2078        if (!(isfinite(w_fisher) && w_fisher > 0.0))
2079            pirls_refuse(&status, PIRLS_FISHER_WEIGHT);
2080#ifdef PIRLS_CURVATURE_OBSERVED
2081        double weighted_ratio_observed = positive_mul_div(w_fisher, y_i, mu);
2082        if (!(isfinite(weighted_ratio_observed) && weighted_ratio_observed > 0.0))
2083            pirls_refuse(&status, PIRLS_OBSERVED_WEIGHT);
2084        w_hessian = weighted_ratio_observed;
2085#else
2086        w_hessian = w_fisher;
2087#endif
2088        if (!isfinite(w_hessian)) pirls_refuse(&status, PIRLS_OBSERVED_WEIGHT);
2089        w_solver = w_hessian;
2090        double u = (y_i - mu) / mu;
2091        double scaled_unit = w_fisher * gamma_unit_deviance_near_one(u);
2092        bool need_weighted_ratio = !isfinite(u)
2093            || !(isfinite(scaled_unit) && scaled_unit >= 0.0);
2094        double weighted_ratio = 0.0;
2095#ifdef PIRLS_CURVATURE_OBSERVED
2096        weighted_ratio = weighted_ratio_observed;
2097#else
2098        if (need_weighted_ratio)
2099            weighted_ratio = positive_mul_div(w_fisher, y_i, mu);
2100#endif
2101        grad_eta = isfinite(u) ? w_fisher * u : weighted_ratio - w_fisher;
2102        double dev_base;
2103        if (isfinite(scaled_unit) && scaled_unit >= 0.0) {{
2104            dev_base = scaled_unit;
2105        }} else {{
2106            dev_base = weighted_ratio - w_fisher * (1.0 + log(y_i) - eta_i);
2107        }}
2108        if (!isfinite(grad_eta)) pirls_refuse(&status, PIRLS_GRADIENT);
2109        dev = 2.0 * dev_base;
2110        if (!isfinite(dev)) pirls_refuse(&status, PIRLS_DEVIANCE);
2111    }}
2112    if (status == PIRLS_OK && !pirls_outputs_finite(
2113            mu, grad_eta, w_fisher, w_hessian, w_solver, dev))
2114        pirls_refuse(&status, PIRLS_FINAL_OUTPUT);
2115"#
2116    )
2117}
2118
2119#[cfg(target_os = "linux")]
2120fn bernoulli_logit_body(curvature: CurvatureMode) -> String {
2121    let tag = curvature_tag(curvature);
2122    format!(
2123        r#"{tag}    double mu = 0.0, grad_eta = 0.0, w_fisher = 0.0;
2124    double w_hessian = 0.0, w_solver = 0.0, dev = 0.0;
2125    if (!isfinite(eta_i)) pirls_refuse(&status, PIRLS_ETA_DOMAIN);
2126    if (status == PIRLS_OK && !(isfinite(wp) && wp >= 0.0))
2127        pirls_refuse(&status, PIRLS_PRIOR_WEIGHT);
2128    if (status == PIRLS_OK && wp > 0.0
2129            && !(isfinite(y_i) && y_i >= 0.0 && y_i <= 1.0))
2130        pirls_refuse(&status, PIRLS_RESPONSE);
2131    double tail = exp(-fabs(eta_i));
2132    double denom = 1.0 + tail;
2133    double dmu_deta = tail / (denom * denom);
2134    if (status == PIRLS_OK) {{
2135        mu = eta_i >= 0.0 ? 1.0 / denom : tail / denom;
2136        if (!(isfinite(mu) && mu >= 0.0 && mu <= 1.0
2137                && isfinite(dmu_deta) && dmu_deta > 0.0))
2138            pirls_refuse(&status, PIRLS_INVERSE_LINK);
2139    }}
2140    if (status == PIRLS_OK && wp > 0.0) {{
2141        double residual;
2142        if (eta_i >= 0.0) {{
2143            double one_minus_mu = tail / denom;
2144            residual = y_i == 1.0 ? one_minus_mu : (y_i - 1.0) + one_minus_mu;
2145        }} else {{
2146            residual = y_i - mu;
2147        }}
2148        w_fisher = wp * dmu_deta;
2149        if (!(isfinite(w_fisher) && w_fisher > 0.0))
2150            pirls_refuse(&status, PIRLS_FISHER_WEIGHT);
2151        w_hessian = w_fisher;
2152        w_solver = w_hessian;
2153        grad_eta = wp * residual;
2154        if (!isfinite(grad_eta)) pirls_refuse(&status, PIRLS_GRADIENT);
2155        dev = logit_deviance(y_i, eta_i, wp);
2156        if (!isfinite(dev)) pirls_refuse(&status, PIRLS_DEVIANCE);
2157    }}
2158    if (status == PIRLS_OK && !pirls_outputs_finite(
2159            mu, grad_eta, w_fisher, w_hessian, w_solver, dev))
2160        pirls_refuse(&status, PIRLS_FINAL_OUTPUT);
2161"#
2162    )
2163}
2164
2165#[cfg(target_os = "linux")]
2166fn bernoulli_probit_body(curvature: CurvatureMode) -> String {
2167    let tag = curvature_tag(curvature);
2168    format!(
2169        r#"{tag}    double mu = 0.0, grad_eta = 0.0, w_fisher = 0.0;
2170    double w_hessian = 0.0, w_solver = 0.0, dev = 0.0;
2171    if (!isfinite(eta_i)) pirls_refuse(&status, PIRLS_ETA_DOMAIN);
2172    if (status == PIRLS_OK && !(isfinite(wp) && wp >= 0.0))
2173        pirls_refuse(&status, PIRLS_PRIOR_WEIGHT);
2174    if (status == PIRLS_OK && wp > 0.0
2175            && !(isfinite(y_i) && y_i >= 0.0 && y_i <= 1.0))
2176        pirls_refuse(&status, PIRLS_RESPONSE);
2177    double dmu_deta = 0.0, d2mu_deta2 = 0.0, v = 0.0;
2178    if (status == PIRLS_OK) {{
2179        mu = std_norm_cdf(eta_i);
2180        dmu_deta = std_norm_pdf(eta_i);
2181        d2mu_deta2 = -eta_i * dmu_deta;
2182        if (!(isfinite(mu) && mu > 0.0 && mu < 1.0
2183                && isfinite(dmu_deta) && dmu_deta > 0.0
2184                && isfinite(d2mu_deta2)))
2185            pirls_refuse(&status, PIRLS_INVERSE_LINK);
2186    }}
2187    if (status == PIRLS_OK && wp > 0.0) {{
2188        v = mu * (1.0 - mu);
2189        double fisher_per_prior = dmu_deta * dmu_deta / v;
2190        w_fisher = wp * fisher_per_prior;
2191        if (!(isfinite(v) && v > 0.0 && isfinite(fisher_per_prior)
2192                && fisher_per_prior > 0.0 && isfinite(w_fisher) && w_fisher > 0.0))
2193            pirls_refuse(&status, PIRLS_FISHER_WEIGHT);
2194        double resid = y_i - mu;
2195#ifdef PIRLS_CURVATURE_OBSERVED
2196        double bracket = d2mu_deta2 / v
2197            - (dmu_deta * dmu_deta) * (1.0 - 2.0 * mu) / (v * v);
2198        w_hessian = w_fisher - wp * resid * bracket;
2199#else
2200        w_hessian = w_fisher;
2201#endif
2202        if (!isfinite(w_hessian)) pirls_refuse(&status, PIRLS_OBSERVED_WEIGHT);
2203        w_solver = w_hessian;
2204        grad_eta = wp * resid * dmu_deta / v;
2205        if (!isfinite(grad_eta)) pirls_refuse(&status, PIRLS_GRADIENT);
2206        dev = bernoulli_deviance(y_i, mu, wp);
2207        if (!isfinite(dev)) pirls_refuse(&status, PIRLS_DEVIANCE);
2208    }}
2209    if (status == PIRLS_OK && !pirls_outputs_finite(
2210            mu, grad_eta, w_fisher, w_hessian, w_solver, dev))
2211        pirls_refuse(&status, PIRLS_FINAL_OUTPUT);
2212"#
2213    )
2214}
2215
2216#[cfg(target_os = "linux")]
2217fn bernoulli_cloglog_body(curvature: CurvatureMode) -> String {
2218    let tag = curvature_tag(curvature);
2219    format!(
2220        r#"{tag}    double mu = 0.0, grad_eta = 0.0, w_fisher = 0.0;
2221    double w_hessian = 0.0, w_solver = 0.0, dev = 0.0;
2222    if (!isfinite(eta_i)) pirls_refuse(&status, PIRLS_ETA_DOMAIN);
2223    if (status == PIRLS_OK && !(isfinite(wp) && wp >= 0.0))
2224        pirls_refuse(&status, PIRLS_PRIOR_WEIGHT);
2225    if (status == PIRLS_OK && wp > 0.0
2226            && !(isfinite(y_i) && y_i >= 0.0 && y_i <= 1.0))
2227        pirls_refuse(&status, PIRLS_RESPONSE);
2228    double inner = 0.0, dmu_deta = 0.0, d2mu_deta2 = 0.0, v = 0.0;
2229    if (status == PIRLS_OK) {{
2230        inner = exp(eta_i);
2231        double complement = exp(-inner);
2232        mu = -expm1(-inner);
2233        dmu_deta = inner * complement;
2234        d2mu_deta2 = dmu_deta * (1.0 - inner);
2235        if (!(isfinite(mu) && mu > 0.0 && mu < 1.0
2236                && isfinite(dmu_deta) && dmu_deta > 0.0
2237                && isfinite(d2mu_deta2)))
2238            pirls_refuse(&status, PIRLS_INVERSE_LINK);
2239    }}
2240    if (status == PIRLS_OK && wp > 0.0) {{
2241        v = mu * (1.0 - mu);
2242        double fisher_per_prior = dmu_deta * dmu_deta / v;
2243        w_fisher = wp * fisher_per_prior;
2244        if (!(isfinite(v) && v > 0.0 && isfinite(fisher_per_prior)
2245                && fisher_per_prior > 0.0 && isfinite(w_fisher) && w_fisher > 0.0))
2246            pirls_refuse(&status, PIRLS_FISHER_WEIGHT);
2247        double resid = y_i - mu;
2248#ifdef PIRLS_CURVATURE_OBSERVED
2249        double bracket = d2mu_deta2 / v
2250            - (dmu_deta * dmu_deta) * (1.0 - 2.0 * mu) / (v * v);
2251        w_hessian = w_fisher - wp * resid * bracket;
2252#else
2253        w_hessian = w_fisher;
2254#endif
2255        if (!isfinite(w_hessian)) pirls_refuse(&status, PIRLS_OBSERVED_WEIGHT);
2256        w_solver = w_hessian;
2257        grad_eta = wp * resid * dmu_deta / v;
2258        if (!isfinite(grad_eta)) pirls_refuse(&status, PIRLS_GRADIENT);
2259        dev = bernoulli_deviance(y_i, mu, wp);
2260        if (!isfinite(dev)) pirls_refuse(&status, PIRLS_DEVIANCE);
2261    }}
2262    if (status == PIRLS_OK && !pirls_outputs_finite(
2263            mu, grad_eta, w_fisher, w_hessian, w_solver, dev))
2264        pirls_refuse(&status, PIRLS_FINAL_OUTPUT);
2265"#
2266    )
2267}
2268
2269// ────────────────────────────────────────────────────────────────────────
2270// solve-row CUDA source (4-output variant)
2271// ────────────────────────────────────────────────────────────────────────
2272
2273/// Build the solve-row CUDA source for `(family, curvature)`.
2274///
2275/// The kernel has a reduced signature:
2276///   `(int n, const f64* eta, const f64* y, const f64* prior_w,
2277///     f64* grad_eta_out, f64* w_solver_out, f64* deviance_out, u32* status_out)`
2278///
2279/// It executes the same per-family math as `cuda_source_for` but skips the
2280/// `mu` and `w_hessian` stores, reducing
2281/// both bandwidth and L1/register pressure in the hot Newton iteration.
2282#[cfg(target_os = "linux")]
2283fn solve_row_source_for(family: PirlsRowFamily, curvature: CurvatureMode) -> String {
2284    let body = match family {
2285        PirlsRowFamily::GaussianIdentity => gaussian_identity_body(curvature),
2286        PirlsRowFamily::PoissonLog => poisson_log_body(curvature),
2287        PirlsRowFamily::GammaLog => gamma_log_body(curvature),
2288        PirlsRowFamily::BernoulliLogit => bernoulli_logit_body(curvature),
2289        PirlsRowFamily::BernoulliProbit => bernoulli_probit_body(curvature),
2290        PirlsRowFamily::BernoulliCLogLog => bernoulli_cloglog_body(curvature),
2291    };
2292    let kernel_name = family.solve_kernel_name();
2293    let curvature_define = match curvature {
2294        CurvatureMode::Fisher => "#define PIRLS_CURVATURE_FISHER 1",
2295        CurvatureMode::Observed => "#define PIRLS_CURVATURE_OBSERVED 1",
2296    };
2297    // GammaLog solve kernel also takes `double shape` after `prior_w`.
2298    let shape_param = if matches!(family, PirlsRowFamily::GammaLog) {
2299        "    double         shape,\n"
2300    } else {
2301        ""
2302    };
2303    format!(
2304        r#"
2305{curvature_define}
2306{prolog}
2307
2308extern "C" __global__ void {kernel_name}(
2309    int            n,
2310    const double* __restrict__ eta,
2311    const double* __restrict__ y,
2312    const double* __restrict__ prior_w,
2313{shape_param}    double* __restrict__ grad_eta_out,
2314    double* __restrict__ w_solver_out,
2315    double* __restrict__ deviance_out,
2316    unsigned int* __restrict__ status_out
2317) {{
2318    int i = blockIdx.x * blockDim.x + threadIdx.x;
2319    if (i >= n) return;
2320    unsigned int status = PIRLS_OK;
2321    double eta_i = eta[i];
2322    double y_i = y[i];
2323    double wp = prior_w[i];
2324{body}
2325    if (status == PIRLS_OK) {{
2326        grad_eta_out[i] = grad_eta;
2327        w_solver_out[i] = w_solver;
2328        deviance_out[i] = dev;
2329    }}
2330    status_out[i] = status;
2331}}
2332"#,
2333        prolog = common_device_prolog(),
2334    )
2335}
2336
2337// ────────────────────────────────────────────────────────────────────────
2338// alpha-ladder CUDA source (fused all-alpha candidate-objective kernel)
2339// ────────────────────────────────────────────────────────────────────────
2340
2341/// The alpha constants embedded into the ladder kernel source as a
2342/// `__constant__` array. Must stay in sync with [`ALPHA_LADDER`].
2343#[cfg(target_os = "linux")]
2344const ALPHA_LADDER_CUDA_ARRAY: &str =
2345    "__constant__ double PIRLS_ALPHAS[7] = {1.0, 0.5, 0.25, 0.125, 0.0625, 0.03125, 0.015625};";
2346
2347/// Build the fused alpha-ladder CUDA source for `(family, curvature)`.
2348///
2349/// Grid layout: `grid = (row_blocks, ALPHA_LADDER_LEN, 1)`.
2350///   - `blockIdx.y` selects the alpha slot `k`.
2351///   - `blockIdx.x * blockDim.x + threadIdx.x` selects row `i`.
2352///
2353/// Each thread evaluates `eta_trial = eta[i] + PIRLS_ALPHAS[k] * xd[i]`,
2354/// runs the per-family deviance math, and atomically adds to
2355/// `objective_out[k]` (double-precision atomic add) and writes the exact row
2356/// code to `status_out[k*n+i]`.
2357///
2358/// The kernel signature is:
2359///   `(int n, const f64* eta, const f64* xd, const f64* y, const f64* prior_w,
2360///     f64* objective_out, u32* status_out)`
2361#[cfg(target_os = "linux")]
2362fn ladder_source_for(family: PirlsRowFamily, curvature: CurvatureMode) -> String {
2363    let body = match family {
2364        PirlsRowFamily::GaussianIdentity => gaussian_identity_body(curvature),
2365        PirlsRowFamily::PoissonLog => poisson_log_body(curvature),
2366        PirlsRowFamily::GammaLog => gamma_log_body(curvature),
2367        PirlsRowFamily::BernoulliLogit => bernoulli_logit_body(curvature),
2368        PirlsRowFamily::BernoulliProbit => bernoulli_probit_body(curvature),
2369        PirlsRowFamily::BernoulliCLogLog => bernoulli_cloglog_body(curvature),
2370    };
2371    let kernel_name = family.ladder_kernel_name();
2372    let curvature_define = match curvature {
2373        CurvatureMode::Fisher => "#define PIRLS_CURVATURE_FISHER 1",
2374        CurvatureMode::Observed => "#define PIRLS_CURVATURE_OBSERVED 1",
2375    };
2376    // The body uses the name `eta_i` for the exact trial linear predictor.
2377    // For the ladder we substitute `eta[i] + alpha * xd[i]` as the trial eta,
2378    // so we define `eta_i` before the body runs. The body's own local variable
2379    // For GaussianIdentity the body reads `eta_i` directly as `mu`; log-link
2380    // bodies certify the shared solver interval and never modify the value.
2381    // GammaLog ladder kernel also takes `double shape` after `prior_w`.
2382    let shape_param = if matches!(family, PirlsRowFamily::GammaLog) {
2383        "    double         shape,\n"
2384    } else {
2385        ""
2386    };
2387    format!(
2388        r#"
2389{curvature_define}
2390{prolog}
2391{alphas}
2392
2393extern "C" __global__ void {kernel_name}(
2394    int            n,
2395    const double* __restrict__ eta,
2396    const double* __restrict__ xd,
2397    const double* __restrict__ y,
2398    const double* __restrict__ prior_w,
2399{shape_param}    double* __restrict__ objective_out,
2400    unsigned int* __restrict__ status_out
2401) {{
2402    int i = blockIdx.x * blockDim.x + threadIdx.x;
2403    int k = (int)blockIdx.y;
2404    if (i >= n) return;
2405    unsigned int status = PIRLS_OK;
2406    double alpha = PIRLS_ALPHAS[k];
2407    double eta_i = eta[i] + alpha * xd[i];
2408    double y_i = y[i];
2409    double wp = prior_w[i];
2410{body}
2411    if (status == PIRLS_OK) atomicAdd(&objective_out[k], dev);
2412    status_out[k * n + i] = status;
2413}}
2414"#,
2415        prolog = common_device_prolog(),
2416        alphas = ALPHA_LADDER_CUDA_ARRAY,
2417    )
2418}
2419
2420// ────────────────────────────────────────────────────────────────────────
2421// Tests
2422// ────────────────────────────────────────────────────────────────────────
2423
2424#[cfg(test)]
2425#[path = "pirls_row_tests.rs"]
2426mod pirls_row_tests;