1use 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#[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) }
79}
80
81pub 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#[derive(Clone, Debug)]
94pub struct PolyaGammaBatchInput<'a> {
95 pub shapes: ArrayView1<'a, u32>,
97 pub tilts: ArrayView1<'a, f64>,
99 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#[inline]
130pub fn splitmix64_mix(z: u64) -> u64 {
131 gam_linalg::utils::splitmix64_hash(z)
132}
133
134const ROW_ZETA: u64 = 0xA1B2_C3D4_E5F6_7890;
138const WORD_GAMMA: u64 = 0x0F1E_2D3C_4B5A_6978;
139
140#[derive(Clone, Copy, Debug)]
144pub struct XorwowState {
145 pub s: [u32; 5],
146 pub d: u32,
147}
148
149impl XorwowState {
150 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 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 #[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 #[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 #[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
221impl 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
246use 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
263pub fn pg1_draw_cpu_oracle(state: &mut XorwowState, tilt: f64) -> f64 {
266 upstream_pg1().draw(state, tilt)
267}
268
269pub 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
276pub fn saddlepoint_solve(x: f64) -> f64 {
295 if (x - 1.0).abs() < 1e-9 {
300 return 0.0;
301 }
302 if x < 1.0 {
303 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 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 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 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
374pub 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
408pub fn pg_saddlepoint_cpu_oracle(state: &mut XorwowState, b: u32, tilt: f64) -> f64 {
413 pg_convolution_cpu_oracle(state, b, tilt)
419}
420
421pub use crate::pg_moments::{pg_mean, pg_variance};
430
431pub 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 if draw <= 0.0 {
442 draw = -draw + 1e-300;
443 }
444 draw
445}
446
447pub 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
476pub 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 }
492 Err(other) => return Err(String::from(other)),
493 }
494 }
495 }
496
497 draw_batch_cpu(&input)
498}
499
500pub 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 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 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 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 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 let l = cholesky_lower_inplace(q.clone())
590 .map_err(|e| format!("logistic_gibbs_step Cholesky: {e}"))?;
591 let mean = cholesky_solve_vector(&l, &m);
593
594 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#[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#[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 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 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 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 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 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 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 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 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 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 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 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#[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 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 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 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 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 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 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 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 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 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 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 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 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 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 (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 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 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 #[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 {
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 #[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 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 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 #[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 #[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 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}