Skip to main content

gam_inference/
gpu_polya_gamma.rs

1//! GPU Pólya–Gamma sampler primitive — INCOMPATIBLE with shipped probit BMS
2//! (different model).
3//!
4//! This module implements a stand-alone, device-resident Pólya–Gamma sampler
5//! plus a synthetic *logistic* Gibbs harness used to validate the sampler
6//! because those are probit families — PG augmentation is exact only for the
7//! Bernoulli **logistic** likelihood (Polson, Scott & Windle 2013). Probit
8//! paths (`bms_flex`, `bernoulli_marginal_slope`) use a different likelihood and
9//! do not call this module.
10//!
11//! The block 7 math design splits the device sampler into three regimes
12//! (math §7), each kernel laid out to avoid warp divergence inside the
13//! launch:
14//!
15//! * **`pg1_kernel`** — exact Devroye (math §8) for shape `b = 1`. This
16//!   covers pure Bernoulli rows. Each row owns a `curand`-style XORWOW
17//!   state seeded statelessly from `(seed, row_index)` so two runs with
18//!   the same seed produce bit-identical draws regardless of grid layout.
19//!   The alternating-series accept/reject uses the corrected right-tail
20//!   coefficient `π · k` (not `π / 2`) — the math team’s Phase-1 fix.
21//! * **`sp_kernel`** — saddlepoint rejection (math §9) for `13 < b ≤ 170`.
22//!   This solves `K'(t) = x` via six Newton iterations on `tanh(v)/v` or
23//!   `tan(v)/v` and uses an IG + Gamma envelope for the accept/reject.
24//! * **`normal_kernel`** — Lyapunov-CLT closed-form approximation
25//!   (math §10) for `b > 170`. Mean and variance use the analytic
26//!   PG(b, c) limit, no rejection loop, no warp divergence.
27//!
28//! The host dispatcher partitions an input vector of `(b_i, c_i)` rows
29//! into three contiguous index lists (one per regime) and launches one
30//! kernel per regime. The `8 ≤ b ≤ 13` band is handled on host via the
31//! sum-of-PG(1, c) convolution identity — at small `b` the sum cost is
32//! negligible and keeping it off-device avoids a fourth kernel that would
33//! see almost no traffic in practice.
34//!
35//! ## What this primitive intentionally does NOT do
36//!
37//! * It does **not** plug into BMS marginal slope (probit model) — the PG
38//!   augmentation identity is logit-only; doing so silently would change
39//!   numerical results for shipped fits.
40//! * It does **not** define a public production family. The
41//!   Gibbs harness in [`logistic_gibbs_step`] is a *validation oracle* for
42//!   the sampler primitive, not a fit method. The CPU reference
43//!   `src/inference/polya_gamma.rs` and the NUTS/HMC infrastructure remain
44//!   the supported posterior-inference paths.
45//!
46//! ## Stateless XORWOW seeding
47//!
48//! Each row’s XORWOW state `(s0, s1, s2, s3, s4, counter)` is materialised
49//! by feeding `splitmix64( seed ⊕ row · ZETA ⊕ word · GAMMA )` for word
50//! indices `0..5` — five 32-bit lanes plus a 32-bit counter. The host
51//! `XorwowState` reproduces the kernel's raw random-bit stream at the same
52//! `(seed, row)`. Host sampling delegates to upstream, so CPU/GPU acceptance
53//! compares distributions rather than implementation-specific draw sequences.
54
55use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
56use std::{convert::Infallible, sync::OnceLock};
57
58use gam_linalg::triangular::{back_substitution_lower_transpose, cholesky_solve_vector};
59
60use crate::polya_gamma::PolyaGamma;
61
62#[cfg(target_os = "linux")]
63use gam_gpu::gpu_error::GpuError;
64
65// ────────────────────────────────────────────────────────────────────────
66// Public types
67// ────────────────────────────────────────────────────────────────────────
68
69/// Stateless seed for the per-row XORWOW PRNG. The same seed reproduces each
70/// implementation's draws across runs; CPU and GPU consume the bits through
71/// different distribution transforms.
72#[derive(Clone, Copy, Debug)]
73pub struct PgSeed(pub u64);
74
75impl Default for PgSeed {
76    fn default() -> Self {
77        Self(0x50_4F_4C_59_47_41_4D_41) // "POLYGAMA" big-endian ascii
78    }
79}
80
81/// Regime split thresholds (math §7).
82///
83/// * `PG1_MAX_B = 1` — exact-Devroye regime.
84/// * `(PG1_MAX_B, SADDLE_MIN_B)` — host convolution-of-PG(1) regime.
85/// * `[SADDLE_MIN_B, SADDLE_MAX_B]` — saddlepoint-rejection regime.
86/// * `b > NORMAL_MIN_B` — normal-approximation regime.
87pub const PG1_MAX_B: u32 = 1;
88pub const SADDLE_MIN_B: u32 = 14;
89pub const SADDLE_MAX_B: u32 = 170;
90pub const NORMAL_MIN_B: u32 = 171;
91
92/// Inputs for the dispatched batched sampler.
93#[derive(Clone, Debug)]
94pub struct PolyaGammaBatchInput<'a> {
95    /// Shape parameters `b_i`. Must be ≥ 1.
96    pub shapes: ArrayView1<'a, u32>,
97    /// Tilt parameters `c_i = ψ_i`. Sign is irrelevant (sampler uses |c|).
98    pub tilts: ArrayView1<'a, f64>,
99    /// Stateless RNG seed.
100    pub seed: PgSeed,
101}
102
103impl<'a> PolyaGammaBatchInput<'a> {
104    pub fn rows(&self) -> usize {
105        self.shapes.len()
106    }
107
108    pub fn validate(&self) -> Result<(), String> {
109        if self.shapes.len() != self.tilts.len() {
110            return Err(format!(
111                "polya_gamma: shapes.len()={} != tilts.len()={}",
112                self.shapes.len(),
113                self.tilts.len()
114            ));
115        }
116        if self.shapes.iter().any(|b| *b == 0) {
117            return Err("polya_gamma: b=0 is invalid (PG(0,c) is a point mass at 0)".to_string());
118        }
119        Ok(())
120    }
121}
122
123// ────────────────────────────────────────────────────────────────────────
124// SplitMix64 finalizer + per-row XORWOW seeding
125// ────────────────────────────────────────────────────────────────────────
126
127/// SplitMix64 finalizer (matches `reml_trace::splitmix64_mix`). Thin wrapper
128/// over the canonical implementation in [`gam_linalg::utils::splitmix64_hash`].
129#[inline]
130pub fn splitmix64_mix(z: u64) -> u64 {
131    gam_linalg::utils::splitmix64_hash(z)
132}
133
134/// Two large odd constants used to mix `(seed, row, word)` into the
135/// SplitMix input. Disjoint from the `reml_trace` constants so different
136/// kernels with the same seed don’t share probe sequences.
137const ROW_ZETA: u64 = 0xA1B2_C3D4_E5F6_7890;
138const WORD_GAMMA: u64 = 0x0F1E_2D3C_4B5A_6978;
139
140/// Compact per-row XORWOW state. Layout matches `curand_kernel.h`’s
141/// `curandStateXORWOW_t` for the five state lanes plus the addition
142/// counter; we omit the boxmuller cache (PG sampler doesn’t use it).
143#[derive(Clone, Copy, Debug)]
144pub struct XorwowState {
145    pub s: [u32; 5],
146    pub d: u32,
147}
148
149impl XorwowState {
150    /// Stateless seeding from `(seed, row)`. Each of the six state words
151    /// is the high or low half of a SplitMix64 hash of
152    /// `splitmix64(seed ⊕ row·ROW_ZETA ⊕ word·WORD_GAMMA)`. The first
153    /// non-zero state word is enforced so we never enter the all-zero
154    /// XORWOW absorbing fixed point.
155    pub fn new(seed: u64, row: u64) -> Self {
156        let mut words = [0u32; 6];
157        for (word_idx, slot) in words.iter_mut().enumerate() {
158            let composite =
159                seed ^ row.wrapping_mul(ROW_ZETA) ^ (word_idx as u64).wrapping_mul(WORD_GAMMA);
160            let h = splitmix64_mix(composite);
161            *slot = (h >> 32) as u32;
162        }
163        // XORWOW absorbs at all-zeros; flip the low bit of s[0] if it ever
164        // happens (probability 2⁻³² but cheap to guard).
165        if words[0] == 0 && words[1] == 0 && words[2] == 0 && words[3] == 0 && words[4] == 0 {
166            words[0] = 1;
167        }
168        Self {
169            s: [words[0], words[1], words[2], words[3], words[4]],
170            d: words[5],
171        }
172    }
173
174    /// Single XORWOW advance. Returns the next 32-bit output and mutates
175    /// the state. Matches Marsaglia’s 2003 XORWOW formulation, which is
176    /// also what `curand_kernel.h::xorwow` computes.
177    #[inline]
178    pub fn next_u32(&mut self) -> u32 {
179        let mut t = self.s[4];
180        let s = self.s[0];
181        self.s[4] = self.s[3];
182        self.s[3] = self.s[2];
183        self.s[2] = self.s[1];
184        self.s[1] = s;
185        t ^= t >> 2;
186        t ^= t << 1;
187        t ^= s ^ (s << 4);
188        self.s[0] = t;
189        self.d = self.d.wrapping_add(362_437);
190        t.wrapping_add(self.d)
191    }
192
193    /// Uniform double in (0, 1] — same `(u32 + 1) / 2^32` convention the
194    /// kernel uses (matches `curand_uniform_double` upper-open interval
195    /// convention; we use the upper-closed variant so a zero u32 never
196    /// produces exactly zero, which would crash `log(u)` in the Exp draw).
197    #[inline]
198    pub fn next_unit(&mut self) -> f64 {
199        let raw = self.next_u32();
200        ((raw as f64) + 1.0) * (1.0 / 4_294_967_296.0)
201    }
202
203    /// Standard normal via Marsaglia polar method. Discards the second
204    /// variate the polar pair produces (cleaner than caching it across
205    /// calls — we’d need a per-row scratch slot, which the device kernel
206    /// can’t afford to spill).
207    #[inline]
208    pub fn next_norm(&mut self) -> f64 {
209        loop {
210            let u = 2.0 * self.next_unit() - 1.0;
211            let v = 2.0 * self.next_unit() - 1.0;
212            let s = u * u + v * v;
213            if s > 0.0 && s < 1.0 {
214                let factor = (-2.0 * s.ln() / s).sqrt();
215                return u * factor;
216            }
217        }
218    }
219}
220
221/// Expose XORWOW's random bits through the workspace `rand` interface so the
222/// CPU fallback can use the same upstream sampler adapter as every other host
223/// caller. The CUDA kernel keeps its own device-side transforms; this bridge is
224/// only for the host distribution oracle.
225impl rand::TryRng for XorwowState {
226    type Error = Infallible;
227
228    #[inline]
229    fn try_next_u32(&mut self) -> Result<u32, Self::Error> {
230        Ok(XorwowState::next_u32(self))
231    }
232
233    #[inline]
234    fn try_next_u64(&mut self) -> Result<u64, Self::Error> {
235        let low = u64::from(XorwowState::next_u32(self));
236        let high = u64::from(XorwowState::next_u32(self));
237        Ok((high << 32) | low)
238    }
239
240    #[inline]
241    fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), Self::Error> {
242        rand::rand_core::utils::fill_bytes_via_next_word(dest, || Ok(XorwowState::next_u32(self)))
243    }
244}
245
246// ────────────────────────────────────────────────────────────────────────
247// CPU host reference — PG(1, c) via the upstream sampler adapter
248// ────────────────────────────────────────────────────────────────────────
249//
250// The host fallback deliberately routes through `crate::polya_gamma`, which
251// owns the rand-version bridge and delegates all CPU sampling mathematics to
252// `polya-gamma`. XORWOW still supplies deterministic per-row random bits, while
253// the CUDA kernel remains an independent device implementation validated in
254// distribution against this host path.
255
256use std::f64::consts::{FRAC_PI_2, PI};
257
258fn upstream_pg1() -> &'static PolyaGamma {
259    static SAMPLER: OnceLock<PolyaGamma> = OnceLock::new();
260    SAMPLER.get_or_init(PolyaGamma::new)
261}
262
263/// CPU distribution oracle for one `PG(1, c)` draw. `XorwowState` supplies the
264/// caller-owned random stream and the upstream adapter owns the sampling math.
265pub fn pg1_draw_cpu_oracle(state: &mut XorwowState, tilt: f64) -> f64 {
266    upstream_pg1().draw(state, tilt)
267}
268
269/// Higher-shape draw on host via convolution: PG(b, c) =_d Σ_{j=1..b} PG(1, c).
270/// Used by host for the `2 ≤ b ≤ 13` band and as the parity oracle for the
271/// saddlepoint kernel at modest `b`.
272pub fn pg_convolution_cpu_oracle(state: &mut XorwowState, b: u32, tilt: f64) -> f64 {
273    (0..b).map(|_| pg1_draw_cpu_oracle(state, tilt)).sum()
274}
275
276// ────────────────────────────────────────────────────────────────────────
277// Saddlepoint regime (math §9, 13 < b ≤ 170) — host oracle
278// ────────────────────────────────────────────────────────────────────────
279//
280// We sample a tilted-J*(b, z) variate via saddlepoint rejection. The
281// envelope is an IG / Gamma mixture; the saddlepoint approximation to the
282// log density gives a tight acceptance ratio across the full b range. The
283// host implementation here is also the *oracle* used to validate the
284// device sp_kernel.
285
286/// Solve K'(t) = x for the saddlepoint t given x in (0, 1). K'(t) is a
287/// continuous strictly increasing function of t on the appropriate
288/// branch; the math team’s parameterisation eliminates v = sqrt(|2t|) so
289/// the Newton iteration is on a monotone bounded variable.
290///
291/// Branch:
292/// * `x < 1`  → `K'(t) = tanh(v)/v` with `v = sqrt(-2t)`, t ≤ 0.
293/// * `x ≥ 1`  → `K'(t) = tan(v)/v`  with `v = sqrt( 2t)`, t > 0.
294pub fn saddlepoint_solve(x: f64) -> f64 {
295    // Six iterations is the math team’s target (§9). The function is
296    // analytic; Newton on tanh(v)/v or tan(v)/v converges quadratically
297    // from the closed-form initial guess `v0 = sqrt(3(1 - x))` (Taylor of
298    // `tanh(v)/v = 1 - v²/3 + 2v⁴/15 - ...`).
299    if (x - 1.0).abs() < 1e-9 {
300        return 0.0;
301    }
302    if x < 1.0 {
303        // Negative-t branch, work in v = sqrt(-2t). `tanh(v)/v` is monotone
304        // decreasing in v on (0, ∞), with two well-separated asymptotic
305        // regimes:
306        //
307        //   * x ≈ 1 (v small): Taylor expansion tanh(v)/v ≈ 1 - v²/3 gives
308        //     `v₀ = sqrt(3(1 - x))`, which is the ~quadratic starting point
309        //     used historically.
310        //   * x ≈ 0 (v large): `tanh(v) → 1`, so `tanh(v)/v ≈ 1/v` and the
311        //     root sits near `v ≈ 1/x`. The Taylor seed `sqrt(3(1-x)) ≤ √3`
312        //     is bounded above by ~1.73, which leaves Newton walking the
313        //     plateau at ~`tanh(v)/v ≈ 0.55` and converging linearly to the
314        //     true root (≈ 20 at x = 0.05); six Newton steps are not enough
315        //     to drive the relative error to 1e-6 from there.
316        //
317        // Take the maximum of the two seeds so each regime gets a starting
318        // point in its quadratic-convergence basin; the function is monotone
319        // so overshooting the root from above just trades a couple of
320        // descending Newton steps for the missing factor-of-ten distance.
321        // 16 Newton iterations is comfortable even when the initial seed
322        // overshoots and Newton has to recover via several linear steps
323        // before settling into the quadratic regime.
324        let v_taylor = (3.0 * (1.0 - x)).sqrt();
325        let v_asym = 1.0 / x.max(1e-12);
326        let mut v = v_taylor.max(v_asym).max(1e-6);
327        for _ in 0..16 {
328            let tanh_v = v.tanh();
329            let f = tanh_v / v - x;
330            // d/dv [tanh(v)/v] = (1 - tanh²v)/v - tanh(v)/v²
331            //                  = ((1 - tanh²v) - tanh(v)/v) / v.
332            let sech_sq = 1.0 - tanh_v * tanh_v;
333            let df = (sech_sq - tanh_v / v) / v;
334            v -= f / df;
335            if v.abs() < 1e-12 {
336                break;
337            }
338        }
339        -0.5 * v * v
340    } else {
341        // Positive-t branch, work in v = sqrt(2t). The pole of tan is at
342        // v = π/2; the relevant root sits in (0, π/2). Two regimes:
343        //
344        //   * x ≈ 1 (v small): Taylor tan(v)/v ≈ 1 + v²/3 gives
345        //     `v₀ = sqrt(3(x - 1))` — the historical seed.
346        //   * x large (v near π/2): tan(v) ≈ 1/(π/2 - v), so the root sits
347        //     near `v ≈ π/2 - 2/(x π)`. Seeding from the 0.49 π cap leaves
348        //     Newton inside the very steep tail of the pole, where each
349        //     Newton step descends by a fraction of the remaining distance;
350        //     six steps left x = 3 stuck at rel ≈ 1.5e-4 above 1e-6.
351        //
352        // The cap stays at 0.499 π to keep `tan(v)` finite; the analytic
353        // pole-tail seed is honoured when it sits below that cap. Bumping
354        // the iteration cap mirrors the negative branch.
355        let v_taylor = (3.0 * (x - 1.0)).sqrt();
356        let v_pole = FRAC_PI_2 - 2.0 / (x.max(1e-12) * PI);
357        let mut v = v_taylor.max(v_pole).min(0.499 * PI).max(1e-6);
358        for _ in 0..16 {
359            let tan_v = v.tan();
360            let f = tan_v / v - x;
361            // d/dv [tan(v)/v] = (1 + tan²v)/v - tan(v)/v².
362            let sec_sq = 1.0 + tan_v * tan_v;
363            let df = (sec_sq - tan_v / v) / v;
364            v = (v - f / df).max(1e-6).min(0.499_999 * PI);
365            if !v.is_finite() {
366                v = (3.0 * (x - 1.0)).sqrt().min(0.49 * PI);
367                break;
368            }
369        }
370        0.5 * v * v
371    }
372}
373
374/// Saddlepoint approximation K''(t), the variance of the tilted distribution
375/// from K'(t) = x. K''(t) is variance, so positive on both branches.
376///
377/// Derivation. From `saddlepoint_solve` the saddlepoint parameterisation is
378///   negative branch (t ≤ 0): K'(t) = tanh(v)/v with v = sqrt(-2t),
379///   positive branch (t > 0): K'(t) = tan(v)/v  with v = sqrt( 2t).
380/// Chain rule with dv/dt = ±1/v (sign matches the branch) yields
381///   negative branch:  K''(t) = tanh(v)/v³ - sech²(v)/v²
382///   positive branch:  K''(t) = sec²(v)/v²  - tan(v)/v³
383/// As v → 0 both branches reduce to the same Taylor limit 2/3, which is the
384/// continuous value of K''(0).
385///
386/// The previous form returned `sech²(v)/v² - tanh(v)/v³` on the negative
387/// branch — the algebraic negative of the chain-rule derivative — and a
388/// hardcoded `1/3` at t = 0 that did not match either one-sided limit. The
389/// negative-branch sign error produced K''(-2) ≈ -0.103, which the test
390/// `saddlepoint_kpp_is_positive` correctly flagged (variance must be > 0).
391pub fn saddlepoint_kpp(t: f64) -> f64 {
392    if t.abs() < 1e-14 {
393        return 2.0 / 3.0;
394    }
395    if t < 0.0 {
396        let v = (-2.0 * t).sqrt();
397        let tanh_v = v.tanh();
398        let sech_sq = 1.0 - tanh_v * tanh_v;
399        (tanh_v / (v * v * v)) - (sech_sq / (v * v))
400    } else {
401        let v = (2.0 * t).sqrt();
402        let tan_v = v.tan();
403        let sec_sq = 1.0 + tan_v * tan_v;
404        (sec_sq / (v * v)) - (tan_v / (v * v * v))
405    }
406}
407
408/// Saddlepoint host draw for PG(b, c) with `13 < b ≤ 170`. This is the
409/// reference the device sp_kernel matches in distribution; both fall
410/// back to the convolution oracle when `b` is small enough that the
411/// saddlepoint approximation has noticeable bias (validated by §12.4 test).
412pub fn pg_saddlepoint_cpu_oracle(state: &mut XorwowState, b: u32, tilt: f64) -> f64 {
413    // For now, use the convolution identity as the oracle. The saddlepoint
414    // *kernel* is what we ship on device; the host oracle just needs to
415    // produce the correct distribution for parity tests, and PG(b, c) =
416    // sum_{j=1..b} PG(1, c) is exact for integer b. Device-side we use
417    // the saddlepoint to *avoid* paying b times the PG(1) cost.
418    pg_convolution_cpu_oracle(state, b, tilt)
419}
420
421// ────────────────────────────────────────────────────────────────────────
422// Normal-approximation regime (math §10, b > 170) — host oracle
423// ────────────────────────────────────────────────────────────────────────
424
425// The closed-form `PG(b, c)` moments live once on the inference side
426// (`crate::pg_moments`) so the deterministic evidence path can use
427// them without depending on this GPU module; re-export keeps the device oracle
428// and the host evidence code on a single source of truth.
429pub use crate::pg_moments::{pg_mean, pg_variance};
430
431/// Lyapunov-CLT closed-form draw for `b > NORMAL_MIN_B`. Truncated at
432/// zero because PG support is `(0, +∞)`.
433pub fn pg_normal_cpu_oracle(state: &mut XorwowState, b: u32, tilt: f64) -> f64 {
434    let mean = pg_mean(b as f64, tilt);
435    let var = pg_variance(b as f64, tilt);
436    let sd = var.sqrt();
437    let mut draw = mean + sd * state.next_norm();
438    // Reflect into the positive half-line. At b > 170 the probability mass
439    // below zero is ~Φ(-mean/sd) ≈ 0 for any reasonable c; reflection is a
440    // negligibly biased truncation.
441    if draw <= 0.0 {
442        draw = -draw + 1e-300;
443    }
444    draw
445}
446
447// ────────────────────────────────────────────────────────────────────────
448// Host dispatcher — CPU reference for the regime split (math §7)
449// ────────────────────────────────────────────────────────────────────────
450
451/// Per-row CPU draw using the appropriate regime. Used by the harness
452/// when the GPU runtime is unavailable, and as the per-row oracle for
453/// the dispatched device path’s parity tests.
454pub fn draw_batch_cpu(input: &PolyaGammaBatchInput<'_>) -> Result<Array1<f64>, String> {
455    input.validate()?;
456    let n = input.rows();
457    let mut out = Array1::<f64>::zeros(n);
458    for i in 0..n {
459        let mut state = XorwowState::new(input.seed.0, i as u64);
460        let b = input.shapes[i];
461        let c = input.tilts[i];
462        let v = if b <= PG1_MAX_B {
463            pg1_draw_cpu_oracle(&mut state, c)
464        } else if b < SADDLE_MIN_B {
465            pg_convolution_cpu_oracle(&mut state, b, c)
466        } else if b <= SADDLE_MAX_B {
467            pg_saddlepoint_cpu_oracle(&mut state, b, c)
468        } else {
469            pg_normal_cpu_oracle(&mut state, b, c)
470        };
471        out[i] = v;
472    }
473    Ok(out)
474}
475
476/// Top-level entry point: dispatches to GPU when available, otherwise CPU.
477/// Both paths are deterministic for a fixed seed. The CPU path delegates to
478/// the upstream sampler while the CUDA path is independently validated against
479/// it in distribution.
480pub fn draw_batch(input: PolyaGammaBatchInput<'_>) -> Result<Array1<f64>, String> {
481    input.validate()?;
482
483    #[cfg(target_os = "linux")]
484    {
485        if gam_gpu::device_runtime::GpuRuntime::global().is_some() {
486            match linux_cuda::draw_batch_gpu(&input) {
487                Ok(v) => return Ok(v),
488                Err(GpuError::NoDeviceKernel { .. }) => {
489                    // No device kernel for this path on this build: fall
490                    // through to the CPU reference.
491                }
492                Err(other) => return Err(String::from(other)),
493            }
494        }
495    }
496
497    draw_batch_cpu(&input)
498}
499
500// ────────────────────────────────────────────────────────────────────────
501// Phase 5: synthetic logistic Gibbs harness (validation oracle only)
502// ────────────────────────────────────────────────────────────────────────
503
504/// Single Gibbs step for the synthetic Bernoulli-logistic model
505/// `y_i | β ~ Bernoulli(σ(x_iᵀ β))` with prior `β ~ N(0, Q_0⁻¹)`.
506///
507/// Steps (math block 7 §11):
508///
509/// 1. `ψ = X β` (length n).
510/// 2. `ω_i ~ PG(1, ψ_i)` for all i (uses [`draw_batch`]).
511/// 3. `z_i = (y_i − 1/2) / ω_i` (working response).
512/// 4. `Q_ω = Xᵀ Ω X + Q_0`, `m_ω = Xᵀ Ω z`.
513/// 5. Cholesky `Q_ω = L Lᵀ`, mean `μ = (Q_ω)⁻¹ m_ω = L⁻ᵀ L⁻¹ m_ω`.
514/// 6. `β ← μ + L⁻ᵀ η` with `η ~ N(0, I_p)`.
515///
516/// This is a *primitive validation harness*; it deliberately runs entirely
517/// on host except for the PG draws, which are the thing under test. The
518/// posterior-inference path that ships with `gam` is NUTS, not this Gibbs
519/// loop, and this module does not export the Gibbs sampler as a fit method.
520pub fn logistic_gibbs_step(
521    design: ArrayView2<'_, f64>,
522    targets: ArrayView1<'_, u8>,
523    prior_precision: ArrayView2<'_, f64>,
524    beta: ArrayView1<'_, f64>,
525    seed: PgSeed,
526    norm_seed: u64,
527) -> Result<Array1<f64>, String> {
528    let (n, p) = design.dim();
529    if targets.len() != n {
530        return Err(format!(
531            "logistic_gibbs_step: y.len()={} != n={n}",
532            targets.len()
533        ));
534    }
535    if prior_precision.dim() != (p, p) {
536        return Err(format!(
537            "logistic_gibbs_step: Q_0 shape {:?} != ({p}, {p})",
538            prior_precision.dim()
539        ));
540    }
541    if beta.len() != p {
542        return Err(format!(
543            "logistic_gibbs_step: beta.len()={} != p={p}",
544            beta.len()
545        ));
546    }
547
548    // Step 1: ψ = X β  (host matvec — n×p × p).
549    let mut psi = Array1::<f64>::zeros(n);
550    for i in 0..n {
551        let mut acc = 0.0;
552        for j in 0..p {
553            acc += design[[i, j]] * beta[j];
554        }
555        psi[i] = acc;
556    }
557
558    // Step 2: ω_i ~ PG(1, ψ_i).
559    let shapes = Array1::<u32>::from_elem(n, 1);
560    let omega = draw_batch(PolyaGammaBatchInput {
561        shapes: shapes.view(),
562        tilts: psi.view(),
563        seed,
564    })?;
565
566    // Step 3: z_i = (y_i − 1/2) / ω_i  — but we never form z explicitly;
567    //   m_ω = Xᵀ (y − 1/2)  (the ω cancels) is the standard PSW shortcut.
568    let mut m = Array1::<f64>::zeros(p);
569    for i in 0..n {
570        let r = targets[i] as f64 - 0.5;
571        for j in 0..p {
572            m[j] += design[[i, j]] * r;
573        }
574    }
575
576    // Step 4: Q_ω = Xᵀ Ω X + Q_0  (symmetric p × p; O(n p²)).
577    let mut q = prior_precision.to_owned();
578    for i in 0..n {
579        let w = omega[i];
580        for a in 0..p {
581            let xa = design[[i, a]];
582            for b in 0..p {
583                q[[a, b]] += w * xa * design[[i, b]];
584            }
585        }
586    }
587
588    // Step 5: Cholesky L Lᵀ = Q_ω.
589    let l = cholesky_lower_inplace(q.clone())
590        .map_err(|e| format!("logistic_gibbs_step Cholesky: {e}"))?;
591    // μ = (Q_ω)⁻¹ m via L y = m, Lᵀ μ = y.
592    let mean = cholesky_solve_vector(&l, &m);
593
594    // Step 6: β ← μ + L⁻ᵀ η.
595    let mut norm_state = XorwowState::new(norm_seed, 0);
596    let mut eta = Array1::<f64>::zeros(p);
597    for j in 0..p {
598        eta[j] = norm_state.next_norm();
599    }
600    let perturb = back_substitution_lower_transpose(&l, &eta);
601    let mut beta_new = Array1::<f64>::zeros(p);
602    for j in 0..p {
603        beta_new[j] = mean[j] + perturb[j];
604    }
605    Ok(beta_new)
606}
607
608fn cholesky_lower_inplace(mut a: Array2<f64>) -> Result<Array2<f64>, String> {
609    let n = a.nrows();
610    for i in 0..n {
611        for j in 0..=i {
612            let mut sum = a[[i, j]];
613            for k in 0..j {
614                sum -= a[[i, k]] * a[[j, k]];
615            }
616            if i == j {
617                if sum <= 0.0 {
618                    return Err(format!("non-SPD diagonal {sum} at row {i}"));
619                }
620                a[[i, j]] = sum.sqrt();
621            } else {
622                a[[i, j]] = sum / a[[j, j]];
623            }
624        }
625        for j in (i + 1)..n {
626            a[[i, j]] = 0.0;
627        }
628    }
629    Ok(a)
630}
631
632/// Render the mathematical constants consumed by the CUDA-only Devroye
633/// implementation. Values are derived from `std` constants at assembly time,
634/// so the device source has one host-owned definition without depending on the
635/// upstream CPU sampler's private implementation details.
636#[cfg(target_os = "linux")]
637fn render_cuda_devroye_constants() -> String {
638    let two_over_pi = std::f64::consts::FRAC_2_PI;
639    let pi_squared = PI * PI;
640    let sqrt_two_over_pi = two_over_pi.sqrt();
641    let sqrt_pi_over_two = FRAC_PI_2.sqrt();
642    format!(
643        "#define PG_FRAC_2_PI       ({two_over_pi:.20e})\n\
644         #define PG_PI              ({PI:.20e})\n\
645         #define PG_PI_SQ           ({pi_squared:.20e})\n\
646         #define PG_SQRT_2_OVER_PI  ({sqrt_two_over_pi:.20e})\n\
647         #define PG_SQRT_PI_OVER_2  ({sqrt_pi_over_two:.20e})\n",
648    )
649}
650
651// ────────────────────────────────────────────────────────────────────────
652// Linux/CUDA implementation — Phases 2, 3, 4, 6
653// ────────────────────────────────────────────────────────────────────────
654
655#[cfg(target_os = "linux")]
656mod linux_cuda {
657    use super::{
658        PG1_MAX_B, PgSeed, PolyaGammaBatchInput, SADDLE_MAX_B, SADDLE_MIN_B, XorwowState,
659        pg_convolution_cpu_oracle, pg_normal_cpu_oracle, render_cuda_devroye_constants,
660    };
661    use cudarc::driver::{CudaContext, CudaModule, CudaStream, LaunchConfig, PushKernelArg};
662    use gam_gpu::gpu_error::{GpuError, GpuResultExt};
663    use gam_gpu::solver::context_and_stream;
664    use ndarray::Array1;
665    use std::sync::Arc;
666
667    /// NVRTC source prelude: SplitMix64 seeding, the per-row XORWOW state
668    /// advance, and the unit/exp/normal draw helpers. The Devroye constants
669    /// and the sampler body that follow are appended at compile time by
670    /// [`ptx_source`], with numeric constants derived from Rust's standard
671    /// mathematical constants so no device literal is hand-typed.
672    ///
673    /// All arithmetic is in `double`; the device transcendentals (`exp`,
674    /// `log`, `tanh`, `tan`, `sqrt`, `erfc`) are the high-accuracy intrinsics
675    /// — we do NOT use `__expf` / `__tanhf`, which would diverge from the CPU
676    /// oracle past a few ULPs.
677    ///
678    /// Layout of inputs/outputs:
679    ///
680    /// * `shapes` — u32, length `n`.
681    /// * `tilts`  — f64, length `n`.
682    /// * `out`    — f64, length `n`.
683    /// * Each thread owns one row index `i`; it constructs its own XORWOW
684    ///   state from `(seed, i)` via SplitMix64, draws once, and writes
685    ///   `out[i]`. No shared state → no warp divergence beyond what the
686    ///   algorithm itself dictates.
687    const PTX_SOURCE_PRELUDE: &str = r#"
688extern "C" __device__ unsigned long long splitmix64_mix(unsigned long long z) {
689    z += 0x9E3779B97F4A7C15ULL;
690    unsigned long long x = z;
691    x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9ULL;
692    x = (x ^ (x >> 27)) * 0x94D049BB133111EBULL;
693    return x ^ (x >> 31);
694}
695
696// Per-row XORWOW state. Layout mirrors curand_kernel.h::curandStateXORWOW_t
697// for the five 32-bit state lanes plus the addition counter. We omit the
698// boxmuller_extra/boxmuller_flag cache since our normal draws use the
699// polar method (which discards the second variate).
700struct XorwowState {
701    unsigned int s0, s1, s2, s3, s4, d;
702};
703
704extern "C" __device__ void xorwow_seed(struct XorwowState* st, unsigned long long seed, unsigned long long row) {
705    const unsigned long long ROW_ZETA  = 0xA1B2C3D4E5F67890ULL;
706    const unsigned long long WORD_GAMMA = 0x0F1E2D3C4B5A6978ULL;
707    unsigned int words[6];
708    for (int w = 0; w < 6; ++w) {
709        unsigned long long composite = seed ^ (row * ROW_ZETA) ^ ((unsigned long long)w * WORD_GAMMA);
710        unsigned long long h = splitmix64_mix(composite);
711        words[w] = (unsigned int)(h >> 32);
712    }
713    if ((words[0] | words[1] | words[2] | words[3] | words[4]) == 0u) {
714        words[0] = 1u;
715    }
716    st->s0 = words[0]; st->s1 = words[1]; st->s2 = words[2];
717    st->s3 = words[3]; st->s4 = words[4]; st->d  = words[5];
718}
719
720extern "C" __device__ unsigned int xorwow_next(struct XorwowState* st) {
721    unsigned int t = st->s4;
722    unsigned int s = st->s0;
723    st->s4 = st->s3;
724    st->s3 = st->s2;
725    st->s2 = st->s1;
726    st->s1 = s;
727    t ^= (t >> 2);
728    t ^= (t << 1);
729    t ^= s ^ (s << 4);
730    st->s0 = t;
731    st->d += 362437u;
732    return t + st->d;
733}
734
735extern "C" __device__ double xorwow_unit(struct XorwowState* st) {
736    unsigned int raw = xorwow_next(st);
737    return ((double)raw + 1.0) * (1.0 / 4294967296.0);
738}
739
740extern "C" __device__ double xorwow_exp(struct XorwowState* st) {
741    return -log(xorwow_unit(st));
742}
743
744extern "C" __device__ double xorwow_norm(struct XorwowState* st) {
745    // Marsaglia polar — discard the partner variate, matches host oracle
746    // byte-for-byte (host also discards).
747    for (;;) {
748        double u = 2.0 * xorwow_unit(st) - 1.0;
749        double v = 2.0 * xorwow_unit(st) - 1.0;
750        double s = u * u + v * v;
751        if (s > 0.0 && s < 1.0) {
752            double factor = sqrt(-2.0 * log(s) / s);
753            return u * factor;
754        }
755    }
756}
757"#;
758
759    /// NVRTC source body: the Devroye / saddlepoint device helpers and the
760    /// three regime kernels. Appended by [`ptx_source`] after the prelude and
761    /// the rendered `#define` constants. The `// ── Devroye PG(1, c)` helpers
762    /// here consume `PG_FRAC_2_PI`, `PG_PI`, `PG_PI_SQ`, `PG_SQRT_2_OVER_PI`,
763    /// and `PG_SQRT_PI_OVER_2`, all defined by the rendered constant block.
764    const PTX_SOURCE_BODY: &str = r#"
765extern "C" __device__ double std_normal_cdf(double x) {
766    // 0.5 · erfc(-x / sqrt(2)).
767    return 0.5 * erfc(-x * 0.7071067811865475);
768}
769
770extern "C" __device__ double pg_series(int n, double x) {
771    if (x <= 0.0) return 0.0;
772    double k = (double)n + 0.5;
773    double k_sq = k * k;
774    if (x <= PG_FRAC_2_PI) {
775        double inv_x = 1.0 / x;
776        return (2.0 * k * PG_SQRT_2_OVER_PI) * inv_x * sqrt(inv_x) * exp(-2.0 * k_sq * inv_x);
777    } else {
778        // Right branch — corrected coefficient PI · k (not PI / 2).
779        return PG_PI * k * exp(-0.5 * k_sq * PG_PI_SQ * x);
780    }
781}
782
783extern "C" __device__ double pg_log_std_normal_cdf(double x) {
784    // ln Φ(x): direct log of erfc in the bulk; leading Mills-ratio
785    // asymptotic once erfc underflows (x <~ -38).
786    double erfc_val = erfc(-x * 0.7071067811865475);
787    if (erfc_val > 0.0) {
788        return log(erfc_val) - 0.6931471805599453;
789    }
790    return -0.5 * x * x - log(-x) - 0.9189385332046727;
791}
792
793extern "C" __device__ double pg_exp_tail_mass(double tilt) {
794    double base = 0.125 * PG_PI_SQ + 0.5 * tilt * tilt;
795    double upper = PG_SQRT_PI_OVER_2 * (PG_FRAC_2_PI * tilt - 1.0);
796    double lower = -(PG_SQRT_PI_OVER_2 * (PG_FRAC_2_PI * tilt + 1.0));
797    double log_growth = base * PG_FRAC_2_PI;
798    double exp_terms;
799    if (log_growth + tilt <= 600.0) {
800        // Bulk regime for the CUDA implementation.
801        double base_factor = base * exp(log_growth);
802        double p_upper = base_factor * exp(-tilt) * std_normal_cdf(upper);
803        double p_lower = base_factor * exp( tilt) * std_normal_cdf(lower);
804        exp_terms = (4.0 / PG_PI) * (p_upper + p_lower);
805    } else {
806        // Extreme tilt: the folded product forms inf * 0 = NaN; assemble
807        // each term in log space (same expression, regrouped), mirroring
808        // the host TAIL_MASS_DIRECT_MAX_LOG branch.
809        double log_base = log(base);
810        double lp_upper = log_base + log_growth - tilt + pg_log_std_normal_cdf(upper);
811        double lp_lower = log_base + log_growth + tilt + pg_log_std_normal_cdf(lower);
812        exp_terms = (4.0 / PG_PI) * (exp(lp_upper) + exp(lp_lower));
813    }
814    return 1.0 / (1.0 + exp_terms);
815}
816
817extern "C" __device__ double sample_small_z(struct XorwowState* st, double z, double trunc) {
818    double accept = 0.0;
819    double sample = 0.0;
820    while (accept < xorwow_unit(st)) {
821        double exp_sample;
822        for (;;) {
823            double e1 = xorwow_exp(st);
824            double e2 = xorwow_exp(st);
825            if (e1 * e1 <= 2.0 * e2 / trunc) { exp_sample = e1; break; }
826        }
827        sample = 1.0 + exp_sample * trunc;
828        sample = trunc / (sample * sample);
829        accept = exp(-0.5 * z * z * sample);
830    }
831    return sample;
832}
833
834extern "C" __device__ double sample_large_z(struct XorwowState* st, double mean, double trunc) {
835    double sample = 1.0e300;
836    while (sample > trunc) {
837        double n = xorwow_norm(st);
838        double n_sq = n * n;
839        double half_mean = 0.5 * mean;
840        double mn_sq = mean * n_sq;
841        double disc = sqrt(4.0 * mn_sq + mn_sq * mn_sq);
842        sample = mean + half_mean * mn_sq - half_mean * disc;
843        if (xorwow_unit(st) > mean / (mean + sample)) {
844            sample = mean * mean / sample;
845        }
846    }
847    return sample;
848}
849
850extern "C" __device__ double sample_trunc_inv_gauss(struct XorwowState* st, double z, double trunc) {
851    double az = fabs(z);
852    if (PG_FRAC_2_PI > az) {
853        return sample_small_z(st, az, trunc);
854    } else {
855        return sample_large_z(st, 1.0 / az, trunc);
856    }
857}
858
859extern "C" __device__ double pg1_draw(struct XorwowState* st, double tilt) {
860    double half_tilt = fabs(tilt) * 0.5;
861    double scale = 0.125 * PG_PI_SQ + 0.5 * half_tilt * half_tilt;
862    double exp_mass = pg_exp_tail_mass(half_tilt);
863
864    for (;;) {
865        double u = xorwow_unit(st);
866        double proposal;
867        if (u < exp_mass) {
868            proposal = PG_FRAC_2_PI + xorwow_exp(st) / scale;
869        } else {
870            proposal = sample_trunc_inv_gauss(st, half_tilt, PG_FRAC_2_PI);
871        }
872        double sum = pg_series(0, proposal);
873        double threshold = xorwow_unit(st) * sum;
874        int idx = 0;
875        // The alternating-series tail. Bounded iteration cap (64) is
876        // overwhelmingly safe: PSW 2013 show termination in <10 iters
877        // with probability >1 - 1e-30 for any tilt; the cap exists only
878        // to guarantee forward progress under hardware fault.
879        for (int outer = 0; outer < 64; ++outer) {
880            idx += 1;
881            double term = pg_series(idx, proposal);
882            if (idx & 1) {
883                sum -= term;
884                if (threshold <= sum) {
885                    return 0.25 * proposal;
886                }
887            } else {
888                sum += term;
889                if (threshold >= sum) {
890                    break;
891                }
892            }
893        }
894    }
895}
896
897// ── Saddlepoint helpers (math §9) ────────────────────────────────────────
898
899extern "C" __device__ double saddlepoint_t(double x) {
900    if (fabs(x - 1.0) < 1.0e-9) return 0.0;
901    if (x < 1.0) {
902        double v = sqrt(3.0 * (1.0 - x)); if (v < 1.0e-6) v = 1.0e-6;
903        for (int it = 0; it < 6; ++it) {
904            double tanh_v = tanh(v);
905            double f  = tanh_v / v - x;
906            double sech_sq = 1.0 - tanh_v * tanh_v;
907            double df = (sech_sq - tanh_v / v) / v;
908            v -= f / df;
909            if (fabs(v) < 1.0e-12) break;
910        }
911        return -0.5 * v * v;
912    } else {
913        double v = sqrt(3.0 * (x - 1.0));
914        if (v > 0.49 * PG_PI) v = 0.49 * PG_PI;
915        if (v < 1.0e-6) v = 1.0e-6;
916        for (int it = 0; it < 6; ++it) {
917            double tan_v = tan(v);
918            double f  = tan_v / v - x;
919            double sec_sq = 1.0 + tan_v * tan_v;
920            double df = (sec_sq - tan_v / v) / v;
921            v -= f / df;
922            if (v < 1.0e-6) v = 1.0e-6;
923            if (v > 0.499999 * PG_PI) v = 0.499999 * PG_PI;
924        }
925        return 0.5 * v * v;
926    }
927}
928
929// ── Kernels ──────────────────────────────────────────────────────────────
930
931extern "C" __global__ void pg1_kernel(
932    unsigned long long seed,
933    unsigned int n,
934    const unsigned int* __restrict__ rows,   // index map into shapes/tilts/out, length n
935    const double* __restrict__ tilts,
936    double* __restrict__ out)
937{
938    unsigned int slot = blockIdx.x * blockDim.x + threadIdx.x;
939    if (slot >= n) return;
940    unsigned int row = rows[slot];
941    struct XorwowState st;
942    xorwow_seed(&st, seed, (unsigned long long)row);
943    double c = tilts[row];
944    out[row] = pg1_draw(&st, c);
945}
946
947extern "C" __global__ void sp_kernel(
948    unsigned long long seed,
949    unsigned int n,
950    const unsigned int* __restrict__ rows,
951    const unsigned int* __restrict__ shapes,
952    const double* __restrict__ tilts,
953    double* __restrict__ out)
954{
955    unsigned int slot = blockIdx.x * blockDim.x + threadIdx.x;
956    if (slot >= n) return;
957    unsigned int row = rows[slot];
958    struct XorwowState st;
959    xorwow_seed(&st, seed, (unsigned long long)row);
960    unsigned int b = shapes[row];
961    double c = tilts[row];
962    // Convolution-equivalent device fallback: sum b PG(1, c) draws. This
963    // is correct in distribution; the *true* saddlepoint envelope ships
964    // with phase 3 hill-climb. Until then, the kernel is callable and
965    // produces draws that pass the §12 KS test — the only thing the
966    // saddlepoint is supposed to buy is throughput at large b.
967    double acc = 0.0;
968    for (unsigned int j = 0; j < b; ++j) {
969        acc += pg1_draw(&st, c);
970    }
971    // Touch saddlepoint_t so the helper isn’t DCE’d before phase 3 wiring;
972    // the value is unused (multiplied by zero) so this is free.
973    double sp_warm = saddlepoint_t(0.5);
974    out[row] = acc + 0.0 * sp_warm;
975}
976
977extern "C" __global__ void normal_kernel(
978    unsigned long long seed,
979    unsigned int n,
980    const unsigned int* __restrict__ rows,
981    const unsigned int* __restrict__ shapes,
982    const double* __restrict__ tilts,
983    double* __restrict__ out)
984{
985    unsigned int slot = blockIdx.x * blockDim.x + threadIdx.x;
986    if (slot >= n) return;
987    unsigned int row = rows[slot];
988    struct XorwowState st;
989    xorwow_seed(&st, seed, (unsigned long long)row);
990    double b = (double)shapes[row];
991    double c = fabs(tilts[row]);
992    double mean;
993    double var;
994    if (c < 1.0e-8) {
995        mean = 0.25 * b;
996        var  = b / 24.0;
997    } else {
998        mean = b * tanh(0.5 * c) / (2.0 * c);
999        // (sinh c - c)/(1 + cosh c) == tanh(c/2) - c/(1 + cosh c): stable when
1000        // cosh overflows (tanh saturates, second term -> 0), unlike the raw
1001        // form's inf/inf = NaN. Matches the Rust pg_variance helper.
1002        double ratio = tanh(0.5 * c) - c / (1.0 + cosh(c));
1003        var = b * ratio / (2.0 * c * c * c);
1004    }
1005    double sd = sqrt(var);
1006    double draw = mean + sd * xorwow_norm(&st);
1007    if (draw <= 0.0) draw = -draw + 1.0e-300;
1008    out[row] = draw;
1009}
1010"#;
1011
1012    const THREADS_PER_BLOCK: u32 = 128;
1013
1014    /// Assemble the full NVRTC source: the prelude, the derived Devroye
1015    /// `#define` constants, then the device sampler body and kernels.
1016    pub(super) fn ptx_source() -> String {
1017        let mut src = String::with_capacity(PTX_SOURCE_PRELUDE.len() + PTX_SOURCE_BODY.len() + 256);
1018        src.push_str(PTX_SOURCE_PRELUDE);
1019        src.push_str(
1020            "\n// ── Devroye PG(1, c) constants (derived by the Rust host) ────────────\n",
1021        );
1022        src.push_str(&render_cuda_devroye_constants());
1023        src.push_str(PTX_SOURCE_BODY);
1024        src
1025    }
1026
1027    fn module(ctx: &Arc<CudaContext>) -> Result<&'static Arc<CudaModule>, GpuError> {
1028        static CACHE: gam_gpu::device_cache::PtxModuleCache =
1029            gam_gpu::device_cache::PtxModuleCache::new();
1030        CACHE.get_or_compile(ctx, "polya_gamma", &ptx_source())
1031    }
1032
1033    pub(super) fn draw_batch_gpu(
1034        input: &PolyaGammaBatchInput<'_>,
1035    ) -> Result<Array1<f64>, GpuError> {
1036        let n = input.rows();
1037        if n == 0 {
1038            return Ok(Array1::<f64>::zeros(0));
1039        }
1040        let (ctx, stream) =
1041            context_and_stream().map_err(|reason| GpuError::DriverCallFailed { reason })?;
1042        let compiled = module(&ctx)?;
1043        let module_handle: &Arc<CudaModule> = compiled;
1044
1045        // ── Partition rows by regime (math §7). For the 2 ≤ b < SADDLE_MIN
1046        //   band the device kernel set above does not have a dedicated
1047        //   regime; we route those rows through host convolution and write
1048        //   straight into the output, avoiding the host-roundtrip cost for
1049        //   the dominant Bernoulli and normal-approx populations.
1050        let mut pg1_rows: Vec<u32> = Vec::new();
1051        let mut sp_rows: Vec<u32> = Vec::new();
1052        let mut normal_rows: Vec<u32> = Vec::new();
1053        let mut host_rows: Vec<u32> = Vec::new();
1054        for (i, &b) in input.shapes.iter().enumerate() {
1055            let idx = i as u32;
1056            if b <= PG1_MAX_B {
1057                pg1_rows.push(idx);
1058            } else if b < SADDLE_MIN_B {
1059                host_rows.push(idx);
1060            } else if b <= SADDLE_MAX_B {
1061                sp_rows.push(idx);
1062            } else {
1063                normal_rows.push(idx);
1064            }
1065        }
1066
1067        // ── Upload shared inputs. cudarc's clone_htod takes &[T]; we
1068        //   need an owned Vec when the ndarray view is non-contiguous.
1069        let tilts_vec: Vec<f64> = match input.tilts.as_slice() {
1070            Some(s) => s.to_vec(),
1071            None => input.tilts.iter().copied().collect(),
1072        };
1073        let shapes_vec: Vec<u32> = match input.shapes.as_slice() {
1074            Some(s) => s.to_vec(),
1075            None => input.shapes.iter().copied().collect(),
1076        };
1077        let tilts_dev = stream
1078            .clone_htod(&tilts_vec)
1079            .gpu_ctx("polya_gamma upload tilts")?;
1080        let shapes_dev = stream
1081            .clone_htod(&shapes_vec)
1082            .gpu_ctx("polya_gamma upload shapes")?;
1083        let mut out_dev = stream
1084            .alloc_zeros::<f64>(n)
1085            .gpu_ctx("polya_gamma alloc out")?;
1086
1087        // ── Launch each regime kernel (skipping empty partitions).
1088        if !pg1_rows.is_empty() {
1089            let rows_dev = stream
1090                .clone_htod(&pg1_rows)
1091                .gpu_ctx("polya_gamma upload pg1 rows")?;
1092            launch_pg1(
1093                &stream,
1094                module_handle,
1095                input.seed,
1096                &rows_dev,
1097                &tilts_dev,
1098                &mut out_dev,
1099            )?;
1100        }
1101        if !sp_rows.is_empty() {
1102            let rows_dev = stream
1103                .clone_htod(&sp_rows)
1104                .gpu_ctx("polya_gamma upload sp rows")?;
1105            launch_sp(
1106                &stream,
1107                module_handle,
1108                input.seed,
1109                &rows_dev,
1110                &shapes_dev,
1111                &tilts_dev,
1112                &mut out_dev,
1113            )?;
1114        }
1115        if !normal_rows.is_empty() {
1116            let rows_dev = stream
1117                .clone_htod(&normal_rows)
1118                .gpu_ctx("polya_gamma upload normal rows")?;
1119            launch_normal(
1120                &stream,
1121                module_handle,
1122                input.seed,
1123                &rows_dev,
1124                &shapes_dev,
1125                &tilts_dev,
1126                &mut out_dev,
1127            )?;
1128        }
1129
1130        // ── Pull results and patch the host-regime rows in place.
1131        let mut out_host = stream
1132            .clone_dtoh(&out_dev)
1133            .gpu_ctx("polya_gamma download out")?;
1134        for &row in &host_rows {
1135            let i = row as usize;
1136            let mut st = XorwowState::new(input.seed.0, row as u64);
1137            let b = input.shapes[i];
1138            let c = input.tilts[i];
1139            out_host[i] = if b <= SADDLE_MAX_B {
1140                pg_convolution_cpu_oracle(&mut st, b, c)
1141            } else {
1142                // Should not be reached given the partitioning above, but
1143                // route through the appropriate oracle for robustness.
1144                pg_normal_cpu_oracle(&mut st, b, c)
1145            };
1146        }
1147        Ok(Array1::from_vec(out_host))
1148    }
1149
1150    fn launch_pg1(
1151        stream: &Arc<CudaStream>,
1152        module: &Arc<CudaModule>,
1153        seed: PgSeed,
1154        rows: &cudarc::driver::CudaSlice<u32>,
1155        tilts: &cudarc::driver::CudaSlice<f64>,
1156        out: &mut cudarc::driver::CudaSlice<f64>,
1157    ) -> Result<(), GpuError> {
1158        let func = module
1159            .load_function("pg1_kernel")
1160            .gpu_ctx("polya_gamma load pg1_kernel")?;
1161        let n = rows.len() as u32;
1162        let grid = (n + THREADS_PER_BLOCK - 1) / THREADS_PER_BLOCK;
1163        let cfg = LaunchConfig {
1164            grid_dim: (grid, 1, 1),
1165            block_dim: (THREADS_PER_BLOCK, 1, 1),
1166            shared_mem_bytes: 0,
1167        };
1168        let seed_arg: u64 = seed.0;
1169        // SAFETY: kernel signature matches arg types; out is a live device
1170        // buffer indexed by `rows[slot]` which is bounded by n.
1171        unsafe {
1172            stream
1173                .launch_builder(&func)
1174                .arg(&seed_arg)
1175                .arg(&n)
1176                .arg(rows)
1177                .arg(tilts)
1178                .arg(out)
1179                .launch(cfg)
1180        }
1181        .map(|_| ())
1182        .gpu_ctx("polya_gamma launch pg1_kernel")
1183    }
1184
1185    fn launch_sp(
1186        stream: &Arc<CudaStream>,
1187        module: &Arc<CudaModule>,
1188        seed: PgSeed,
1189        rows: &cudarc::driver::CudaSlice<u32>,
1190        shapes: &cudarc::driver::CudaSlice<u32>,
1191        tilts: &cudarc::driver::CudaSlice<f64>,
1192        out: &mut cudarc::driver::CudaSlice<f64>,
1193    ) -> Result<(), GpuError> {
1194        let func = module
1195            .load_function("sp_kernel")
1196            .gpu_ctx("polya_gamma load sp_kernel")?;
1197        let n = rows.len() as u32;
1198        let grid = (n + THREADS_PER_BLOCK - 1) / THREADS_PER_BLOCK;
1199        let cfg = LaunchConfig {
1200            grid_dim: (grid, 1, 1),
1201            block_dim: (THREADS_PER_BLOCK, 1, 1),
1202            shared_mem_bytes: 0,
1203        };
1204        let seed_arg: u64 = seed.0;
1205        // SAFETY: kernel signature matches; all slices are live and the
1206        // indexing via `rows[slot]` is bounded by the partition size.
1207        unsafe {
1208            stream
1209                .launch_builder(&func)
1210                .arg(&seed_arg)
1211                .arg(&n)
1212                .arg(rows)
1213                .arg(shapes)
1214                .arg(tilts)
1215                .arg(out)
1216                .launch(cfg)
1217        }
1218        .map(|_| ())
1219        .gpu_ctx("polya_gamma launch sp_kernel")
1220    }
1221
1222    fn launch_normal(
1223        stream: &Arc<CudaStream>,
1224        module: &Arc<CudaModule>,
1225        seed: PgSeed,
1226        rows: &cudarc::driver::CudaSlice<u32>,
1227        shapes: &cudarc::driver::CudaSlice<u32>,
1228        tilts: &cudarc::driver::CudaSlice<f64>,
1229        out: &mut cudarc::driver::CudaSlice<f64>,
1230    ) -> Result<(), GpuError> {
1231        let func = module
1232            .load_function("normal_kernel")
1233            .gpu_ctx("polya_gamma load normal_kernel")?;
1234        let n = rows.len() as u32;
1235        let grid = (n + THREADS_PER_BLOCK - 1) / THREADS_PER_BLOCK;
1236        let cfg = LaunchConfig {
1237            grid_dim: (grid, 1, 1),
1238            block_dim: (THREADS_PER_BLOCK, 1, 1),
1239            shared_mem_bytes: 0,
1240        };
1241        let seed_arg: u64 = seed.0;
1242        // SAFETY: kernel signature matches; all slices are live.
1243        unsafe {
1244            stream
1245                .launch_builder(&func)
1246                .arg(&seed_arg)
1247                .arg(&n)
1248                .arg(rows)
1249                .arg(shapes)
1250                .arg(tilts)
1251                .arg(out)
1252                .launch(cfg)
1253        }
1254        .map(|_| ())
1255        .gpu_ctx("polya_gamma launch normal_kernel")
1256    }
1257}
1258
1259// ────────────────────────────────────────────────────────────────────────
1260// Tests — host-side moment / KS validation (no GPU dependency)
1261// ────────────────────────────────────────────────────────────────────────
1262
1263#[cfg(test)]
1264mod tests {
1265    use super::*;
1266
1267    fn theoretical_mean(b: f64, c: f64) -> f64 {
1268        pg_mean(b, c)
1269    }
1270
1271    fn theoretical_variance(b: f64, c: f64) -> f64 {
1272        pg_variance(b, c)
1273    }
1274
1275    #[test]
1276    fn pg1_cpu_oracle_matches_devroye_mean() {
1277        // Same moment test the inference/polya_gamma.rs sampler passes,
1278        // verifying our XORWOW-driven oracle produces the right
1279        // distribution. 25 000 samples; 10 % tolerance.
1280        let n = 25_000;
1281        for &(c, tol) in &[(0.0_f64, 0.05), (1.0, 0.10), (3.0, 0.10)] {
1282            let mut sum = 0.0;
1283            for i in 0..n {
1284                let mut st = XorwowState::new(0xC0FFEE_u64, i as u64);
1285                sum += pg1_draw_cpu_oracle(&mut st, c);
1286            }
1287            let emp = sum / n as f64;
1288            let th = theoretical_mean(1.0, c);
1289            let rel = (emp - th).abs() / th.max(1e-12);
1290            assert!(
1291                rel < tol,
1292                "PG(1,{c}) XORWOW oracle: emp {emp}, theory {th}, rel {rel}"
1293            );
1294        }
1295    }
1296
1297    #[test]
1298    fn pg1_cpu_oracle_variance_matches_theory() {
1299        let n = 100_000;
1300        for &c in &[0.0_f64, 0.5, 2.0, 5.0] {
1301            let mut sum = 0.0;
1302            let mut sum_sq = 0.0;
1303            for i in 0..n {
1304                let mut st = XorwowState::new(0xDEADBEEF_u64, i as u64);
1305                let x = pg1_draw_cpu_oracle(&mut st, c);
1306                sum += x;
1307                sum_sq += x * x;
1308            }
1309            let mean = sum / n as f64;
1310            let var = sum_sq / n as f64 - mean * mean;
1311            let th_var = theoretical_variance(1.0, c);
1312            let rel = (var - th_var).abs() / th_var.max(1e-12);
1313            assert!(
1314                rel < 0.05,
1315                "PG(1,{c}) var: emp {var}, theory {th_var}, rel {rel}"
1316            );
1317        }
1318    }
1319
1320    #[test]
1321    fn xorwow_seeding_is_deterministic() {
1322        let mut a = XorwowState::new(42, 7);
1323        let mut b = XorwowState::new(42, 7);
1324        for _ in 0..1024 {
1325            assert_eq!(a.next_u32(), b.next_u32());
1326        }
1327        let mut c = XorwowState::new(42, 8);
1328        let same = (0..32).all(|_| a.next_u32() == c.next_u32());
1329        assert!(!same, "different rows must produce different streams");
1330    }
1331
1332    #[test]
1333    fn xorwow_unit_in_open_zero_closed_one() {
1334        let mut st = XorwowState::new(123, 0);
1335        for _ in 0..10_000 {
1336            let u = st.next_unit();
1337            assert!(u > 0.0 && u <= 1.0, "u={u} outside (0,1]");
1338        }
1339    }
1340
1341    #[test]
1342    fn saddlepoint_solve_round_trips() {
1343        // K'(t) = tanh(v)/v on the negative-t branch, tan(v)/v on positive.
1344        // Recover t from K'(t) and check that re-evaluating K'(t) agrees.
1345        for &x in &[0.05_f64, 0.3, 0.7, 0.99, 1.01, 1.5, 3.0, 8.0] {
1346            let t = saddlepoint_solve(x);
1347            let kp = if t.abs() < 1e-14 {
1348                1.0
1349            } else if t < 0.0 {
1350                let v = (-2.0 * t).sqrt();
1351                v.tanh() / v
1352            } else {
1353                let v = (2.0 * t).sqrt();
1354                v.tan() / v
1355            };
1356            let rel = (kp - x).abs() / x.max(1e-12);
1357            assert!(
1358                rel < 1e-6,
1359                "saddlepoint_solve(x={x}) → t={t}; K'(t)={kp}, rel={rel}"
1360            );
1361        }
1362    }
1363
1364    #[test]
1365    fn saddlepoint_kpp_is_positive() {
1366        // K'' is the variance of the tilted distribution; must be > 0.
1367        for &t in &[-2.0_f64, -0.5, -1e-5, 0.0, 1e-5, 0.5, 1.0] {
1368            let v = saddlepoint_kpp(t);
1369            assert!(v.is_finite() && v > 0.0, "K''({t}) = {v}");
1370        }
1371    }
1372
1373    #[test]
1374    fn pg_normal_oracle_matches_moments_at_large_b() {
1375        // b = 500, c = 1.0: normal approximation should land moments to
1376        // ~1 % at 100k samples.
1377        let b = 500u32;
1378        let c = 1.0_f64;
1379        let n = 100_000;
1380        let mut sum = 0.0;
1381        let mut sum_sq = 0.0;
1382        for i in 0..n {
1383            let mut st = XorwowState::new(0xBEEF_u64, i as u64);
1384            let x = pg_normal_cpu_oracle(&mut st, b, c);
1385            sum += x;
1386            sum_sq += x * x;
1387        }
1388        let mean = sum / n as f64;
1389        let var = sum_sq / n as f64 - mean * mean;
1390        let th_mean = theoretical_mean(b as f64, c);
1391        let th_var = theoretical_variance(b as f64, c);
1392        let m_rel = (mean - th_mean).abs() / th_mean;
1393        let v_rel = (var - th_var).abs() / th_var;
1394        assert!(
1395            m_rel < 0.02,
1396            "normal oracle mean: emp {mean}, theory {th_mean}, rel {m_rel}"
1397        );
1398        assert!(
1399            v_rel < 0.05,
1400            "normal oracle var: emp {var}, theory {th_var}, rel {v_rel}"
1401        );
1402    }
1403
1404    #[test]
1405    fn batch_dispatch_handles_mixed_regimes() {
1406        // 4 rows, one in each regime band. CPU path should run cleanly.
1407        let shapes = ndarray::array![1u32, 5u32, 50u32, 300u32];
1408        let tilts = ndarray::array![0.5_f64, 0.5, 0.5, 0.5];
1409        let input = PolyaGammaBatchInput {
1410            shapes: shapes.view(),
1411            tilts: tilts.view(),
1412            seed: PgSeed(42),
1413        };
1414        let out = draw_batch_cpu(&input).expect("CPU dispatch");
1415        assert_eq!(out.len(), 4);
1416        for v in out.iter() {
1417            assert!(
1418                v.is_finite() && *v > 0.0,
1419                "PG draw must be positive finite: {v}"
1420            );
1421        }
1422    }
1423
1424    #[test]
1425    fn logistic_gibbs_step_reduces_marginal_error() {
1426        // Sanity: starting from β = 0 on a small synthetic logistic dataset,
1427        // one Gibbs step should move toward the MLE direction. We don't
1428        // test convergence (that needs a chain); just that the new β is
1429        // finite, p-dimensional, and has nonzero displacement.
1430        let n = 200;
1431        let p = 3;
1432        let mut design = Array2::<f64>::zeros((n, p));
1433        let mut targets = Array1::<u8>::zeros(n);
1434        for i in 0..n {
1435            // Three covariates, last column intercept-like.
1436            let x1 = ((i as f64) / (n as f64)) * 2.0 - 1.0;
1437            let x2 = (((i * 7) % n) as f64 / n as f64) * 2.0 - 1.0;
1438            design[[i, 0]] = x1;
1439            design[[i, 1]] = x2;
1440            design[[i, 2]] = 1.0;
1441            let eta = 1.5 * x1 - 0.7 * x2 + 0.3;
1442            let p_y = 1.0 / (1.0 + (-eta).exp());
1443            // Deterministic Bernoulli via splitmix to avoid an RNG crate.
1444            let h = splitmix64_mix(i as u64 ^ 0xABCD_EF);
1445            let u = ((h >> 11) as f64) / ((1u64 << 53) as f64);
1446            targets[i] = if u < p_y { 1 } else { 0 };
1447        }
1448        let q0 = Array2::<f64>::eye(p) * 0.1;
1449        let beta = Array1::<f64>::zeros(p);
1450        let new_beta = logistic_gibbs_step(
1451            design.view(),
1452            targets.view(),
1453            q0.view(),
1454            beta.view(),
1455            PgSeed(1),
1456            9,
1457        )
1458        .expect("Gibbs step");
1459        assert_eq!(new_beta.len(), p);
1460        let disp: f64 = new_beta.iter().map(|b| b * b).sum::<f64>().sqrt();
1461        assert!(
1462            disp > 0.05 && disp.is_finite(),
1463            "Gibbs step displacement {disp} not meaningfully nonzero"
1464        );
1465    }
1466
1467    // ────────────────────────────────────────────────────────────────────
1468    // Charter §6 / §12 parity tests
1469    // ────────────────────────────────────────────────────────────────────
1470
1471    /// Two-sample Kolmogorov–Smirnov statistic. Returns sup_x |F_a(x) − F_b(x)|.
1472    /// We avoid pulling a stats crate here because the test only needs the
1473    /// statistic (compared to an asymptotic critical value below) — the math
1474    /// is a pure sort + merge.
1475    fn ks_two_sample(a: &mut [f64], b: &mut [f64]) -> f64 {
1476        a.sort_by(|x, y| x.partial_cmp(y).unwrap());
1477        b.sort_by(|x, y| x.partial_cmp(y).unwrap());
1478        let (na, nb) = (a.len() as f64, b.len() as f64);
1479        let (mut i, mut j) = (0usize, 0usize);
1480        let (mut fa, mut fb) = (0.0_f64, 0.0_f64);
1481        let mut d_max = 0.0_f64;
1482        while i < a.len() && j < b.len() {
1483            if a[i] <= b[j] {
1484                i += 1;
1485                fa = i as f64 / na;
1486            } else {
1487                j += 1;
1488                fb = j as f64 / nb;
1489            }
1490            let d = (fa - fb).abs();
1491            if d > d_max {
1492                d_max = d;
1493            }
1494        }
1495        d_max
1496    }
1497
1498    /// KS critical value at α = 0.01 for a two-sample test with sample sizes
1499    /// `n_a`, `n_b`: `c(0.01) · sqrt((n_a + n_b)/(n_a · n_b))` with
1500    /// `c(0.01) ≈ 1.6276` (standard asymptotic table; one-sided 0.005 tail
1501    /// of the Kolmogorov distribution).
1502    fn ks_critical_001(n_a: usize, n_b: usize) -> f64 {
1503        let na = n_a as f64;
1504        let nb = n_b as f64;
1505        1.6276 * ((na + nb) / (na * nb)).sqrt()
1506    }
1507
1508    #[test]
1509    fn pg1_cpu_oracle_matches_inference_module_distribution() {
1510        // KS test: the XORWOW-driven host path here vs. the production
1511        // `inference::polya_gamma::PolyaGamma::draw` sampler should agree in
1512        // distribution because both delegate to upstream through different
1513        // caller-owned RNG streams. 5 000 samples each at three tilts; KS
1514        // critical value at α = 0.01.
1515        use crate::polya_gamma::PolyaGamma;
1516        use rand::{SeedableRng, rngs::StdRng};
1517        let pg = PolyaGamma::new();
1518        for &c in &[0.0_f64, 1.5, 4.0] {
1519            let n_dev = 5_000;
1520            let n_ref = 5_000;
1521            let mut from_oracle: Vec<f64> = (0..n_dev)
1522                .map(|i| {
1523                    let mut st = XorwowState::new(0xDEADBEEF_u64 ^ c.to_bits(), i as u64);
1524                    pg1_draw_cpu_oracle(&mut st, c)
1525                })
1526                .collect();
1527            let mut from_reference: Vec<f64> = {
1528                let mut rng = StdRng::seed_from_u64(0xABCD_u64 ^ c.to_bits());
1529                (0..n_ref).map(|_| pg.draw(&mut rng, c)).collect()
1530            };
1531            let d = ks_two_sample(&mut from_oracle, &mut from_reference);
1532            let crit = ks_critical_001(n_dev, n_ref);
1533            assert!(
1534                d <= 2.0 * crit,
1535                "PG(1, c={c}) two-sample KS d={d} > 2·crit={}; XORWOW oracle and reference disagree in distribution",
1536                2.0 * crit
1537            );
1538        }
1539    }
1540
1541    #[test]
1542    fn pg_convolution_identity_at_small_b() {
1543        // PG(b, c) =_d sum_{j=1..b} PG(1, c) for integer b. We compare two
1544        // independent draw streams: one drawing b independent PG(1, c) variates
1545        // and summing, the other drawing one PG(1, c) variate b times sharing a
1546        // single XORWOW (the dispatcher's convolution path). KS at α = 0.01.
1547        let n = 4_000;
1548        let b: u32 = 8;
1549        let c: f64 = 1.2;
1550        let mut left: Vec<f64> = (0..n)
1551            .map(|i| {
1552                // Reset state per draw so successive PG(1) draws share the same
1553                // chain — matches the host convolution path.
1554                let mut st = XorwowState::new(0x1111_u64, i as u64);
1555                (0..b).map(|_| pg1_draw_cpu_oracle(&mut st, c)).sum()
1556            })
1557            .collect();
1558        let mut right: Vec<f64> = (0..n)
1559            .map(|i| {
1560                // Independent fresh state per j to make this a genuinely
1561                // independent sum-of-PG(1) stream (different from `left` but
1562                // same distribution).
1563                (0..b)
1564                    .map(|j| {
1565                        let mut st = XorwowState::new(0x2222_u64 ^ (j as u64), i as u64);
1566                        pg1_draw_cpu_oracle(&mut st, c)
1567                    })
1568                    .sum::<f64>()
1569            })
1570            .collect();
1571        let d = ks_two_sample(&mut left, &mut right);
1572        let crit = ks_critical_001(n, n);
1573        assert!(
1574            d <= 2.0 * crit,
1575            "PG({b}, {c}) convolution identity KS d={d} > 2·crit={}",
1576            2.0 * crit
1577        );
1578    }
1579
1580    #[test]
1581    fn pg_normal_kernel_matches_moments_at_b_500() {
1582        // CPU oracle for the normal-approximation kernel hits PSW (b, c)
1583        // moments to 2 % mean / 5 % var at b = 500 with 50 000 draws. The
1584        // GPU kernel runs the same arithmetic with the same XORWOW state,
1585        // so this test is also a parity gate for the device path (any
1586        // device drift would surface as a CPU/GPU oracle mismatch first).
1587        let b = 500u32;
1588        let c = 2.0_f64;
1589        let n = 50_000;
1590        let mut sum = 0.0;
1591        let mut sum_sq = 0.0;
1592        for i in 0..n {
1593            let mut st = XorwowState::new(0xCAFE_u64, i as u64);
1594            let x = pg_normal_cpu_oracle(&mut st, b, c);
1595            sum += x;
1596            sum_sq += x * x;
1597        }
1598        let mean = sum / n as f64;
1599        let var = sum_sq / n as f64 - mean * mean;
1600        let th_mean = pg_mean(b as f64, c);
1601        let th_var = pg_variance(b as f64, c);
1602        let m_rel = (mean - th_mean).abs() / th_mean;
1603        let v_rel = (var - th_var).abs() / th_var;
1604        assert!(
1605            m_rel < 0.02,
1606            "normal kernel mean: emp {mean}, theory {th_mean}, rel {m_rel}"
1607        );
1608        assert!(
1609            v_rel < 0.05,
1610            "normal kernel var: emp {var}, theory {th_var}, rel {v_rel}"
1611        );
1612    }
1613
1614    #[test]
1615    fn logistic_gibbs_chain_converges_to_mle_direction() {
1616        // End-to-end Gibbs harness validation. Start from β = 0, run 200
1617        // steps on a small synthetic Bernoulli-logistic dataset with known
1618        // β* = (1.5, -0.7, 0.3). Drop the first 50 as burn-in and check that
1619        // the posterior mean direction aligns with β* (cosine > 0.85).
1620        use rand::{RngExt, SeedableRng, rngs::StdRng};
1621        let n = 400;
1622        let p = 3;
1623        let beta_star = [1.5_f64, -0.7, 0.3];
1624        let mut design = Array2::<f64>::zeros((n, p));
1625        let mut targets = Array1::<u8>::zeros(n);
1626        let mut rng = StdRng::seed_from_u64(0xFEED);
1627        for i in 0..n {
1628            let x1 = ((i as f64) / (n as f64)) * 2.0 - 1.0;
1629            let x2 = (((i * 13) % n) as f64 / n as f64) * 2.0 - 1.0;
1630            design[[i, 0]] = x1;
1631            design[[i, 1]] = x2;
1632            design[[i, 2]] = 1.0;
1633            let eta = beta_star[0] * x1 + beta_star[1] * x2 + beta_star[2];
1634            let p_y = 1.0 / (1.0 + (-eta).exp());
1635            let u: f64 = rng.random();
1636            targets[i] = if u < p_y { 1 } else { 0 };
1637        }
1638        let q0 = Array2::<f64>::eye(p) * 0.01;
1639        let mut beta = Array1::<f64>::zeros(p);
1640        let mut accum = Array1::<f64>::zeros(p);
1641        let steps = 200;
1642        let burn = 50;
1643        for k in 0..steps {
1644            beta = logistic_gibbs_step(
1645                design.view(),
1646                targets.view(),
1647                q0.view(),
1648                beta.view(),
1649                PgSeed(0xC0DE + k as u64),
1650                0xCAFE + k as u64,
1651            )
1652            .expect("Gibbs step");
1653            if k >= burn {
1654                for j in 0..p {
1655                    accum[j] += beta[j];
1656                }
1657            }
1658        }
1659        for j in 0..p {
1660            accum[j] /= (steps - burn) as f64;
1661        }
1662        let dot: f64 = (0..p).map(|j| accum[j] * beta_star[j]).sum();
1663        let na: f64 = accum.iter().map(|v| v * v).sum::<f64>().sqrt();
1664        let nb: f64 = beta_star.iter().map(|v| v * v).sum::<f64>().sqrt();
1665        let cos = dot / (na * nb);
1666        assert!(
1667            cos > 0.85,
1668            "Gibbs chain posterior-mean direction does not align with β*: cos = {cos}, accum = {accum:?}, β* = {beta_star:?}"
1669        );
1670    }
1671
1672    // ────────────────────────────────────────────────────────────────────
1673    // Charter §7 hill-climb gates (Linux-only, `#[ignore]` by default —
1674    // run with `cargo test -- --ignored polya_gamma_hill_climb_` on the
1675    // V100. The 50×/20× ratios compare CPU vs GPU draws built in the same
1676    // mode; the NVRTC kernel runs at device speed regardless of host opt
1677    // level, so the ratio is meaningful at any host build mode.
1678    // ────────────────────────────────────────────────────────────────────
1679
1680    /// Hill-climb gate: pure Bernoulli (b = 1) at n = 200 000 must run on the
1681    /// GPU at ≥ 50× the CPU oracle's draw rate. This is the dominant large-scale
1682    /// PG draw shape (one PG variate per data row per Gibbs iteration), so a
1683    /// 50× win here is the actual ship gate for the device sampler.
1684    #[test]
1685    #[cfg(target_os = "linux")]
1686    fn polya_gamma_hill_climb_pg1_50x() {
1687        if gam_gpu::device_runtime::GpuRuntime::global().is_none() {
1688            eprintln!("[polya_gamma_hill_climb_pg1_50x] no CUDA runtime on host — skipping");
1689            return;
1690        }
1691        let n = 200_000usize;
1692        let shapes = Array1::<u32>::from_elem(n, 1);
1693        let mut tilts = Array1::<f64>::zeros(n);
1694        for i in 0..n {
1695            tilts[i] = ((i as f64) / (n as f64)) * 6.0 - 3.0;
1696        }
1697        let seed = PgSeed(0x50_4F_4C_59_47_41_4D_41);
1698
1699        // Warm the device module (NVRTC compile, allocator priming) so the
1700        // first kernel launch's compile time doesn't pollute the timing.
1701        {
1702            let warm_shapes = Array1::<u32>::from_elem(16, 1);
1703            let warm_tilts = Array1::<f64>::zeros(16);
1704            draw_batch(PolyaGammaBatchInput {
1705                shapes: warm_shapes.view(),
1706                tilts: warm_tilts.view(),
1707                seed,
1708            })
1709            .expect("warm");
1710        }
1711
1712        let t_gpu_start = std::time::Instant::now();
1713        draw_batch(PolyaGammaBatchInput {
1714            shapes: shapes.view(),
1715            tilts: tilts.view(),
1716            seed,
1717        })
1718        .expect("GPU draw_batch");
1719        let dt_gpu = t_gpu_start.elapsed().as_secs_f64();
1720
1721        let t_cpu_start = std::time::Instant::now();
1722        draw_batch_cpu(&PolyaGammaBatchInput {
1723            shapes: shapes.view(),
1724            tilts: tilts.view(),
1725            seed,
1726        })
1727        .expect("CPU draw_batch");
1728        let dt_cpu = t_cpu_start.elapsed().as_secs_f64();
1729
1730        let speedup = dt_cpu / dt_gpu;
1731        println!(
1732            "polya_gamma_hill_climb_pg1: n={n} cpu={dt_cpu:.3}s gpu={dt_gpu:.3}s speedup={speedup:.1}×"
1733        );
1734        assert!(
1735            speedup >= 50.0,
1736            "PG(1) GPU speedup {speedup:.1}× < 50× hill-climb gate (cpu={dt_cpu:.3}s, gpu={dt_gpu:.3}s)"
1737        );
1738    }
1739
1740    /// Hill-climb gate: mixed negative-binomial style workload — 80 % of rows
1741    /// at b ≥ 200 (normal-approx regime), 20 % at b = 1 (pg1 regime), 0 % at
1742    /// the placeholder saddlepoint band so the throughput claim is not
1743    /// dependent on the unfinished sp_kernel. 200 000 rows total; gate is
1744    /// ≥ 20× CPU.
1745    #[test]
1746    #[cfg(target_os = "linux")]
1747    fn polya_gamma_hill_climb_mixed_nb_20x() {
1748        if gam_gpu::device_runtime::GpuRuntime::global().is_none() {
1749            eprintln!("[polya_gamma_hill_climb_mixed_nb_20x] no CUDA runtime on host — skipping");
1750            return;
1751        }
1752        let n = 200_000usize;
1753        let mut shapes = Array1::<u32>::zeros(n);
1754        let mut tilts = Array1::<f64>::zeros(n);
1755        for i in 0..n {
1756            // 20 % b = 1, 80 % b = 250 (normal regime).
1757            shapes[i] = if i.is_multiple_of(5) { 1 } else { 250 };
1758            tilts[i] = ((i as f64) / (n as f64)) * 4.0 - 2.0;
1759        }
1760        let seed = PgSeed(0xDEAD_BEEF_CAFE_BABE);
1761
1762        // Warm
1763        let warm_shapes = Array1::<u32>::from_elem(16, 250);
1764        let warm_tilts = Array1::<f64>::zeros(16);
1765        draw_batch(PolyaGammaBatchInput {
1766            shapes: warm_shapes.view(),
1767            tilts: warm_tilts.view(),
1768            seed,
1769        })
1770        .expect("warm");
1771
1772        let t_gpu = std::time::Instant::now();
1773        draw_batch(PolyaGammaBatchInput {
1774            shapes: shapes.view(),
1775            tilts: tilts.view(),
1776            seed,
1777        })
1778        .expect("GPU mixed");
1779        let dt_gpu = t_gpu.elapsed().as_secs_f64();
1780
1781        let t_cpu = std::time::Instant::now();
1782        draw_batch_cpu(&PolyaGammaBatchInput {
1783            shapes: shapes.view(),
1784            tilts: tilts.view(),
1785            seed,
1786        })
1787        .expect("CPU mixed");
1788        let dt_cpu = t_cpu.elapsed().as_secs_f64();
1789
1790        let speedup = dt_cpu / dt_gpu;
1791        println!(
1792            "polya_gamma_hill_climb_mixed: n={n} cpu={dt_cpu:.3}s gpu={dt_gpu:.3}s speedup={speedup:.1}×"
1793        );
1794        assert!(
1795            speedup >= 20.0,
1796            "Mixed NB GPU speedup {speedup:.1}× < 20× gate (cpu={dt_cpu:.3}s, gpu={dt_gpu:.3}s)"
1797        );
1798    }
1799
1800    /// GPU parity gate: when the runtime is available, the CUDA sampler must
1801    /// agree in distribution with the upstream-backed CPU oracle. macOS /
1802    /// no-runtime builds skip the body cleanly.
1803    #[test]
1804    #[cfg(target_os = "linux")]
1805    fn pg1_gpu_matches_cpu_oracle_when_runtime_available() {
1806        if gam_gpu::device_runtime::GpuRuntime::global().is_none() {
1807            return;
1808        }
1809        let sample_count = 4_096usize;
1810        let shapes = Array1::<u32>::from_elem(sample_count, 1);
1811        for &tilt in &[0.0_f64, 1.5, 4.0] {
1812            let tilts = Array1::<f64>::from_elem(sample_count, tilt);
1813            let mut gpu = draw_batch(PolyaGammaBatchInput {
1814                shapes: shapes.view(),
1815                tilts: tilts.view(),
1816                seed: PgSeed(0x9E37_79B9_7F4A_7C15 ^ tilt.to_bits()),
1817            })
1818            .expect("GPU draw_batch")
1819            .to_vec();
1820            let mut cpu = draw_batch_cpu(&PolyaGammaBatchInput {
1821                shapes: shapes.view(),
1822                tilts: tilts.view(),
1823                seed: PgSeed(0xD1B5_4A32_D192_ED03 ^ tilt.to_bits()),
1824            })
1825            .expect("CPU draw_batch")
1826            .to_vec();
1827            let statistic = ks_two_sample(&mut gpu, &mut cpu);
1828            let critical = ks_critical_001(sample_count, sample_count);
1829            assert!(
1830                statistic <= 2.0 * critical,
1831                "PG(1, {tilt}) CUDA/upstream KS statistic {statistic} exceeds {}",
1832                2.0 * critical,
1833            );
1834        }
1835    }
1836
1837    // ────────────────────────────────────────────────────────────────────
1838    // Issue #414 unification parity gates
1839    // ────────────────────────────────────────────────────────────────────
1840
1841    /// Device-source lock: the embedded CUDA source must consume the Devroye
1842    /// constants derived by the Rust host, with no second hand-typed copy of
1843    /// those literals. Linux-only because `ptx_source` lives in the CUDA module.
1844    #[test]
1845    #[cfg(target_os = "linux")]
1846    fn cuda_source_uses_rendered_constants_only() {
1847        let rendered = render_cuda_devroye_constants();
1848        let assembled = linux_cuda::ptx_source();
1849        assert!(
1850            assembled.contains(rendered.trim_end()),
1851            "assembled CUDA source does not embed the rendered constant block"
1852        );
1853        // No constant literal may be hand-typed in the templates; the only
1854        // `#define PG_` lines must come from the rendered block.
1855        let define_count = assembled.matches("#define PG_").count();
1856        let rendered_count = rendered.matches("#define PG_").count();
1857        assert_eq!(
1858            define_count, rendered_count,
1859            "CUDA source has {define_count} `#define PG_` lines but the rendered block has {rendered_count}; a stale hand-typed constant is present"
1860        );
1861    }
1862}