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#[derive(Clone, Copy, Debug)]
70pub struct PgSeed(pub u64);
71
72impl Default for PgSeed {
73 fn default() -> Self {
74 Self(0x50_4F_4C_59_47_41_4D_41) }
76}
77
78pub const PG1_MAX_B: u32 = 1;
85pub const SADDLE_MIN_B: u32 = 14;
86pub const SADDLE_MAX_B: u32 = 170;
87pub const NORMAL_MIN_B: u32 = 171;
88
89#[derive(Clone, Debug)]
91pub struct PolyaGammaBatchInput<'a> {
92 pub shapes: ArrayView1<'a, u32>,
94 pub tilts: ArrayView1<'a, f64>,
96 pub seed: PgSeed,
98}
99
100impl<'a> PolyaGammaBatchInput<'a> {
101 pub fn rows(&self) -> usize {
102 self.shapes.len()
103 }
104
105 pub fn validate(&self) -> Result<(), String> {
106 if self.shapes.len() != self.tilts.len() {
107 return Err(format!(
108 "polya_gamma: shapes.len()={} != tilts.len()={}",
109 self.shapes.len(),
110 self.tilts.len()
111 ));
112 }
113 if self.shapes.iter().any(|b| *b == 0) {
114 return Err("polya_gamma: b=0 is invalid (PG(0,c) is a point mass at 0)".to_string());
115 }
116 Ok(())
117 }
118}
119
120#[inline]
127pub fn splitmix64_mix(z: u64) -> u64 {
128 gam_linalg::utils::splitmix64_hash(z)
129}
130
131const ROW_ZETA: u64 = 0xA1B2_C3D4_E5F6_7890;
135const WORD_GAMMA: u64 = 0x0F1E_2D3C_4B5A_6978;
136
137#[derive(Clone, Copy, Debug)]
141pub struct XorwowState {
142 pub s: [u32; 5],
143 pub d: u32,
144}
145
146impl XorwowState {
147 pub fn new(seed: u64, row: u64) -> Self {
153 let mut words = [0u32; 6];
154 for (word_idx, slot) in words.iter_mut().enumerate() {
155 let composite =
156 seed ^ row.wrapping_mul(ROW_ZETA) ^ (word_idx as u64).wrapping_mul(WORD_GAMMA);
157 let h = splitmix64_mix(composite);
158 *slot = (h >> 32) as u32;
159 }
160 if words[0] == 0 && words[1] == 0 && words[2] == 0 && words[3] == 0 && words[4] == 0 {
163 words[0] = 1;
164 }
165 Self {
166 s: [words[0], words[1], words[2], words[3], words[4]],
167 d: words[5],
168 }
169 }
170
171 #[inline]
175 pub fn next_u32(&mut self) -> u32 {
176 let mut t = self.s[4];
177 let s = self.s[0];
178 self.s[4] = self.s[3];
179 self.s[3] = self.s[2];
180 self.s[2] = self.s[1];
181 self.s[1] = s;
182 t ^= t >> 2;
183 t ^= t << 1;
184 t ^= s ^ (s << 4);
185 self.s[0] = t;
186 self.d = self.d.wrapping_add(362_437);
187 t.wrapping_add(self.d)
188 }
189
190 #[inline]
195 pub fn next_unit(&mut self) -> f64 {
196 let raw = self.next_u32();
197 ((raw as f64) + 1.0) * (1.0 / 4_294_967_296.0)
198 }
199
200 #[inline]
205 pub fn next_norm(&mut self) -> f64 {
206 loop {
207 let u = 2.0 * self.next_unit() - 1.0;
208 let v = 2.0 * self.next_unit() - 1.0;
209 let s = u * u + v * v;
210 if s > 0.0 && s < 1.0 {
211 let factor = (-2.0 * s.ln() / s).sqrt();
212 return u * factor;
213 }
214 }
215 }
216}
217
218impl rand::TryRng for XorwowState {
223 type Error = Infallible;
224
225 #[inline]
226 fn try_next_u32(&mut self) -> Result<u32, Self::Error> {
227 Ok(XorwowState::next_u32(self))
228 }
229
230 #[inline]
231 fn try_next_u64(&mut self) -> Result<u64, Self::Error> {
232 let low = u64::from(XorwowState::next_u32(self));
233 let high = u64::from(XorwowState::next_u32(self));
234 Ok((high << 32) | low)
235 }
236
237 #[inline]
238 fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), Self::Error> {
239 rand::rand_core::utils::fill_bytes_via_next_word(dest, || Ok(XorwowState::next_u32(self)))
240 }
241}
242
243use std::f64::consts::{FRAC_PI_2, PI};
254
255fn upstream_pg1() -> &'static PolyaGamma {
256 static SAMPLER: OnceLock<PolyaGamma> = OnceLock::new();
257 SAMPLER.get_or_init(PolyaGamma::new)
258}
259
260pub fn pg1_draw_cpu_oracle(state: &mut XorwowState, tilt: f64) -> f64 {
263 upstream_pg1().draw(state, tilt)
264}
265
266pub fn pg_convolution_cpu_oracle(state: &mut XorwowState, b: u32, tilt: f64) -> f64 {
270 (0..b).map(|_| pg1_draw_cpu_oracle(state, tilt)).sum()
271}
272
273pub fn saddlepoint_solve(x: f64) -> f64 {
292 if (x - 1.0).abs() < 1e-9 {
297 return 0.0;
298 }
299 if x < 1.0 {
300 let v_taylor = (3.0 * (1.0 - x)).sqrt();
322 let v_asym = 1.0 / x.max(1e-12);
323 let mut v = v_taylor.max(v_asym).max(1e-6);
324 for _ in 0..16 {
325 let tanh_v = v.tanh();
326 let f = tanh_v / v - x;
327 let sech_sq = 1.0 - tanh_v * tanh_v;
330 let df = (sech_sq - tanh_v / v) / v;
331 v -= f / df;
332 if v.abs() < 1e-12 {
333 break;
334 }
335 }
336 -0.5 * v * v
337 } else {
338 let v_taylor = (3.0 * (x - 1.0)).sqrt();
353 let v_pole = FRAC_PI_2 - 2.0 / (x.max(1e-12) * PI);
354 let mut v = v_taylor.max(v_pole).min(0.499 * PI).max(1e-6);
355 for _ in 0..16 {
356 let tan_v = v.tan();
357 let f = tan_v / v - x;
358 let sec_sq = 1.0 + tan_v * tan_v;
360 let df = (sec_sq - tan_v / v) / v;
361 v = (v - f / df).max(1e-6).min(0.499_999 * PI);
362 if !v.is_finite() {
363 v = (3.0 * (x - 1.0)).sqrt().min(0.49 * PI);
364 break;
365 }
366 }
367 0.5 * v * v
368 }
369}
370
371pub fn saddlepoint_kpp(t: f64) -> f64 {
389 if t.abs() < 1e-14 {
390 return 2.0 / 3.0;
391 }
392 if t < 0.0 {
393 let v = (-2.0 * t).sqrt();
394 let tanh_v = v.tanh();
395 let sech_sq = 1.0 - tanh_v * tanh_v;
396 (tanh_v / (v * v * v)) - (sech_sq / (v * v))
397 } else {
398 let v = (2.0 * t).sqrt();
399 let tan_v = v.tan();
400 let sec_sq = 1.0 + tan_v * tan_v;
401 (sec_sq / (v * v)) - (tan_v / (v * v * v))
402 }
403}
404
405pub fn pg_saddlepoint_cpu_oracle(state: &mut XorwowState, b: u32, tilt: f64) -> f64 {
410 pg_convolution_cpu_oracle(state, b, tilt)
416}
417
418pub use crate::pg_moments::{pg_mean, pg_variance};
427
428pub fn pg_normal_cpu_oracle(state: &mut XorwowState, b: u32, tilt: f64) -> f64 {
431 let mean = pg_mean(b as f64, tilt);
432 let var = pg_variance(b as f64, tilt);
433 let sd = var.sqrt();
434 let mut draw = mean + sd * state.next_norm();
435 if draw <= 0.0 {
439 draw = -draw + 1e-300;
440 }
441 draw
442}
443
444#[derive(Clone, Copy, Debug, PartialEq, Eq)]
449enum PolyaGammaCpuRegime {
450 ExactPg1,
451 ExactConvolution,
452 Saddlepoint,
453 NormalApproximation,
454}
455
456#[inline]
457fn cpu_regime_for_shape(shape: u32) -> PolyaGammaCpuRegime {
458 if shape <= PG1_MAX_B {
459 PolyaGammaCpuRegime::ExactPg1
460 } else if shape < SADDLE_MIN_B {
461 PolyaGammaCpuRegime::ExactConvolution
462 } else if shape <= SADDLE_MAX_B {
463 PolyaGammaCpuRegime::Saddlepoint
464 } else {
465 PolyaGammaCpuRegime::NormalApproximation
466 }
467}
468
469pub fn draw_batch_cpu(input: &PolyaGammaBatchInput<'_>) -> Result<Array1<f64>, String> {
473 input.validate()?;
474 let n = input.rows();
475 let mut out = Array1::<f64>::zeros(n);
476 for i in 0..n {
477 let mut state = XorwowState::new(input.seed.0, i as u64);
478 let b = input.shapes[i];
479 let c = input.tilts[i];
480 let v = match cpu_regime_for_shape(b) {
481 PolyaGammaCpuRegime::ExactPg1 => pg1_draw_cpu_oracle(&mut state, c),
482 PolyaGammaCpuRegime::ExactConvolution => pg_convolution_cpu_oracle(&mut state, b, c),
483 PolyaGammaCpuRegime::Saddlepoint => pg_saddlepoint_cpu_oracle(&mut state, b, c),
484 PolyaGammaCpuRegime::NormalApproximation => pg_normal_cpu_oracle(&mut state, b, c),
485 };
486 out[i] = v;
487 }
488 Ok(out)
489}
490
491pub fn draw_batch(input: PolyaGammaBatchInput<'_>) -> Result<Array1<f64>, String> {
499 input.validate()?;
500
501 #[cfg(target_os = "linux")]
502 {
503 if let Some(runtime) =
504 gam_gpu::device_runtime::GpuRuntime::resolve_if_fused_batch_exceeds_floor(
505 gam_gpu::global_policy(),
506 input.rows(),
507 )
508 .map_err(String::from)?
509 {
510 if runtime
511 .policy()
512 .polya_gamma_batch_target_is_gpu(input.rows())
513 {
514 return linux_cuda::draw_batch_gpu(&input).map_err(String::from);
515 }
516 }
517 }
518
519 draw_batch_cpu(&input)
520}
521
522pub fn logistic_gibbs_step(
543 design: ArrayView2<'_, f64>,
544 targets: ArrayView1<'_, u8>,
545 prior_precision: ArrayView2<'_, f64>,
546 beta: ArrayView1<'_, f64>,
547 seed: PgSeed,
548 norm_seed: u64,
549) -> Result<Array1<f64>, String> {
550 let (n, p) = design.dim();
551 if targets.len() != n {
552 return Err(format!(
553 "logistic_gibbs_step: y.len()={} != n={n}",
554 targets.len()
555 ));
556 }
557 if prior_precision.dim() != (p, p) {
558 return Err(format!(
559 "logistic_gibbs_step: Q_0 shape {:?} != ({p}, {p})",
560 prior_precision.dim()
561 ));
562 }
563 if beta.len() != p {
564 return Err(format!(
565 "logistic_gibbs_step: beta.len()={} != p={p}",
566 beta.len()
567 ));
568 }
569
570 let mut psi = Array1::<f64>::zeros(n);
572 for i in 0..n {
573 let mut acc = 0.0;
574 for j in 0..p {
575 acc += design[[i, j]] * beta[j];
576 }
577 psi[i] = acc;
578 }
579
580 let shapes = Array1::<u32>::from_elem(n, 1);
582 let omega = draw_batch(PolyaGammaBatchInput {
583 shapes: shapes.view(),
584 tilts: psi.view(),
585 seed,
586 })?;
587
588 let mut m = Array1::<f64>::zeros(p);
591 for i in 0..n {
592 let r = targets[i] as f64 - 0.5;
593 for j in 0..p {
594 m[j] += design[[i, j]] * r;
595 }
596 }
597
598 let mut q = prior_precision.to_owned();
600 for i in 0..n {
601 let w = omega[i];
602 for a in 0..p {
603 let xa = design[[i, a]];
604 for b in 0..p {
605 q[[a, b]] += w * xa * design[[i, b]];
606 }
607 }
608 }
609
610 let l = cholesky_lower_inplace(q.clone())
612 .map_err(|e| format!("logistic_gibbs_step Cholesky: {e}"))?;
613 let mean = cholesky_solve_vector(&l, &m);
615
616 let mut norm_state = XorwowState::new(norm_seed, 0);
618 let mut eta = Array1::<f64>::zeros(p);
619 for j in 0..p {
620 eta[j] = norm_state.next_norm();
621 }
622 let perturb = back_substitution_lower_transpose(&l, &eta);
623 let mut beta_new = Array1::<f64>::zeros(p);
624 for j in 0..p {
625 beta_new[j] = mean[j] + perturb[j];
626 }
627 Ok(beta_new)
628}
629
630fn cholesky_lower_inplace(mut a: Array2<f64>) -> Result<Array2<f64>, String> {
631 let n = a.nrows();
632 for i in 0..n {
633 for j in 0..=i {
634 let mut sum = a[[i, j]];
635 for k in 0..j {
636 sum -= a[[i, k]] * a[[j, k]];
637 }
638 if i == j {
639 if sum <= 0.0 {
640 return Err(format!("non-SPD diagonal {sum} at row {i}"));
641 }
642 a[[i, j]] = sum.sqrt();
643 } else {
644 a[[i, j]] = sum / a[[j, j]];
645 }
646 }
647 for j in (i + 1)..n {
648 a[[i, j]] = 0.0;
649 }
650 }
651 Ok(a)
652}
653
654#[cfg(target_os = "linux")]
659fn render_cuda_devroye_constants() -> String {
660 let two_over_pi = std::f64::consts::FRAC_2_PI;
661 let pi_squared = PI * PI;
662 let sqrt_two_over_pi = two_over_pi.sqrt();
663 let sqrt_pi_over_two = FRAC_PI_2.sqrt();
664 format!(
665 "#define PG_FRAC_2_PI ({two_over_pi:.20e})\n\
666 #define PG_PI ({PI:.20e})\n\
667 #define PG_PI_SQ ({pi_squared:.20e})\n\
668 #define PG_SQRT_2_OVER_PI ({sqrt_two_over_pi:.20e})\n\
669 #define PG_SQRT_PI_OVER_2 ({sqrt_pi_over_two:.20e})\n",
670 )
671}
672
673#[cfg(target_os = "linux")]
678mod linux_cuda {
679 use super::{
680 PG1_MAX_B, PgSeed, PolyaGammaBatchInput, SADDLE_MAX_B, SADDLE_MIN_B, XorwowState,
681 pg_convolution_cpu_oracle, pg_normal_cpu_oracle, render_cuda_devroye_constants,
682 };
683 use cudarc::driver::{CudaContext, CudaModule, CudaStream, LaunchConfig, PushKernelArg};
684 use gam_gpu::gpu_error::{GpuError, GpuResultExt};
685 use gam_gpu::solver::context_and_stream;
686 use ndarray::Array1;
687 use std::sync::Arc;
688
689 const PTX_SOURCE_PRELUDE: &str = r#"
710extern "C" __device__ unsigned long long splitmix64_mix(unsigned long long z) {
711 z += 0x9E3779B97F4A7C15ULL;
712 unsigned long long x = z;
713 x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9ULL;
714 x = (x ^ (x >> 27)) * 0x94D049BB133111EBULL;
715 return x ^ (x >> 31);
716}
717
718// Per-row XORWOW state. Layout mirrors curand_kernel.h::curandStateXORWOW_t
719// for the five 32-bit state lanes plus the addition counter. We omit the
720// boxmuller_extra/boxmuller_flag cache since our normal draws use the
721// polar method (which discards the second variate).
722struct XorwowState {
723 unsigned int s0, s1, s2, s3, s4, d;
724};
725
726extern "C" __device__ void xorwow_seed(struct XorwowState* st, unsigned long long seed, unsigned long long row) {
727 const unsigned long long ROW_ZETA = 0xA1B2C3D4E5F67890ULL;
728 const unsigned long long WORD_GAMMA = 0x0F1E2D3C4B5A6978ULL;
729 unsigned int words[6];
730 for (int w = 0; w < 6; ++w) {
731 unsigned long long composite = seed ^ (row * ROW_ZETA) ^ ((unsigned long long)w * WORD_GAMMA);
732 unsigned long long h = splitmix64_mix(composite);
733 words[w] = (unsigned int)(h >> 32);
734 }
735 if ((words[0] | words[1] | words[2] | words[3] | words[4]) == 0u) {
736 words[0] = 1u;
737 }
738 st->s0 = words[0]; st->s1 = words[1]; st->s2 = words[2];
739 st->s3 = words[3]; st->s4 = words[4]; st->d = words[5];
740}
741
742extern "C" __device__ unsigned int xorwow_next(struct XorwowState* st) {
743 unsigned int t = st->s4;
744 unsigned int s = st->s0;
745 st->s4 = st->s3;
746 st->s3 = st->s2;
747 st->s2 = st->s1;
748 st->s1 = s;
749 t ^= (t >> 2);
750 t ^= (t << 1);
751 t ^= s ^ (s << 4);
752 st->s0 = t;
753 st->d += 362437u;
754 return t + st->d;
755}
756
757extern "C" __device__ double xorwow_unit(struct XorwowState* st) {
758 unsigned int raw = xorwow_next(st);
759 return ((double)raw + 1.0) * (1.0 / 4294967296.0);
760}
761
762extern "C" __device__ double xorwow_exp(struct XorwowState* st) {
763 return -log(xorwow_unit(st));
764}
765
766extern "C" __device__ double xorwow_norm(struct XorwowState* st) {
767 // Marsaglia polar — discard the partner variate, matches host oracle
768 // byte-for-byte (host also discards).
769 for (;;) {
770 double u = 2.0 * xorwow_unit(st) - 1.0;
771 double v = 2.0 * xorwow_unit(st) - 1.0;
772 double s = u * u + v * v;
773 if (s > 0.0 && s < 1.0) {
774 double factor = sqrt(-2.0 * log(s) / s);
775 return u * factor;
776 }
777 }
778}
779"#;
780
781 const PTX_SOURCE_BODY: &str = r#"
787extern "C" __device__ double std_normal_cdf(double x) {
788 // 0.5 · erfc(-x / sqrt(2)).
789 return 0.5 * erfc(-x * 0.7071067811865475);
790}
791
792extern "C" __device__ double pg_series(int n, double x) {
793 if (x <= 0.0) return 0.0;
794 double k = (double)n + 0.5;
795 double k_sq = k * k;
796 if (x <= PG_FRAC_2_PI) {
797 double inv_x = 1.0 / x;
798 return (2.0 * k * PG_SQRT_2_OVER_PI) * inv_x * sqrt(inv_x) * exp(-2.0 * k_sq * inv_x);
799 } else {
800 // Right branch — corrected coefficient PI · k (not PI / 2).
801 return PG_PI * k * exp(-0.5 * k_sq * PG_PI_SQ * x);
802 }
803}
804
805extern "C" __device__ double pg_log_std_normal_cdf(double x) {
806 // ln Φ(x): direct log of erfc in the bulk; leading Mills-ratio
807 // asymptotic once erfc underflows (x <~ -38).
808 double erfc_val = erfc(-x * 0.7071067811865475);
809 if (erfc_val > 0.0) {
810 return log(erfc_val) - 0.6931471805599453;
811 }
812 return -0.5 * x * x - log(-x) - 0.9189385332046727;
813}
814
815extern "C" __device__ double pg_exp_tail_mass(double tilt) {
816 double base = 0.125 * PG_PI_SQ + 0.5 * tilt * tilt;
817 double upper = PG_SQRT_PI_OVER_2 * (PG_FRAC_2_PI * tilt - 1.0);
818 double lower = -(PG_SQRT_PI_OVER_2 * (PG_FRAC_2_PI * tilt + 1.0));
819 double log_growth = base * PG_FRAC_2_PI;
820 double exp_terms;
821 if (log_growth + tilt <= 600.0) {
822 // Bulk regime for the CUDA implementation.
823 double base_factor = base * exp(log_growth);
824 double p_upper = base_factor * exp(-tilt) * std_normal_cdf(upper);
825 double p_lower = base_factor * exp( tilt) * std_normal_cdf(lower);
826 exp_terms = (4.0 / PG_PI) * (p_upper + p_lower);
827 } else {
828 // Extreme tilt: the folded product forms inf * 0 = NaN; assemble
829 // each term in log space (same expression, regrouped), mirroring
830 // the host TAIL_MASS_DIRECT_MAX_LOG branch.
831 double log_base = log(base);
832 double lp_upper = log_base + log_growth - tilt + pg_log_std_normal_cdf(upper);
833 double lp_lower = log_base + log_growth + tilt + pg_log_std_normal_cdf(lower);
834 exp_terms = (4.0 / PG_PI) * (exp(lp_upper) + exp(lp_lower));
835 }
836 return 1.0 / (1.0 + exp_terms);
837}
838
839extern "C" __device__ double sample_small_z(struct XorwowState* st, double z, double trunc) {
840 double accept = 0.0;
841 double sample = 0.0;
842 while (accept < xorwow_unit(st)) {
843 double exp_sample;
844 for (;;) {
845 double e1 = xorwow_exp(st);
846 double e2 = xorwow_exp(st);
847 if (e1 * e1 <= 2.0 * e2 / trunc) { exp_sample = e1; break; }
848 }
849 sample = 1.0 + exp_sample * trunc;
850 sample = trunc / (sample * sample);
851 accept = exp(-0.5 * z * z * sample);
852 }
853 return sample;
854}
855
856extern "C" __device__ double sample_large_z(struct XorwowState* st, double mean, double trunc) {
857 double sample = 1.0e300;
858 while (sample > trunc) {
859 double n = xorwow_norm(st);
860 double n_sq = n * n;
861 double half_mean = 0.5 * mean;
862 double mn_sq = mean * n_sq;
863 double disc = sqrt(4.0 * mn_sq + mn_sq * mn_sq);
864 sample = mean + half_mean * mn_sq - half_mean * disc;
865 if (xorwow_unit(st) > mean / (mean + sample)) {
866 sample = mean * mean / sample;
867 }
868 }
869 return sample;
870}
871
872extern "C" __device__ double sample_trunc_inv_gauss(struct XorwowState* st, double z, double trunc) {
873 double az = fabs(z);
874 if (PG_FRAC_2_PI > az) {
875 return sample_small_z(st, az, trunc);
876 } else {
877 return sample_large_z(st, 1.0 / az, trunc);
878 }
879}
880
881extern "C" __device__ double pg1_draw(struct XorwowState* st, double tilt) {
882 double half_tilt = fabs(tilt) * 0.5;
883 double scale = 0.125 * PG_PI_SQ + 0.5 * half_tilt * half_tilt;
884 double exp_mass = pg_exp_tail_mass(half_tilt);
885
886 for (;;) {
887 double u = xorwow_unit(st);
888 double proposal;
889 if (u < exp_mass) {
890 proposal = PG_FRAC_2_PI + xorwow_exp(st) / scale;
891 } else {
892 proposal = sample_trunc_inv_gauss(st, half_tilt, PG_FRAC_2_PI);
893 }
894 double sum = pg_series(0, proposal);
895 double threshold = xorwow_unit(st) * sum;
896 int idx = 0;
897 // The alternating-series tail. Bounded iteration cap (64) is
898 // overwhelmingly safe: PSW 2013 show termination in <10 iters
899 // with probability >1 - 1e-30 for any tilt; the cap exists only
900 // to guarantee forward progress under hardware fault.
901 for (int outer = 0; outer < 64; ++outer) {
902 idx += 1;
903 double term = pg_series(idx, proposal);
904 if (idx & 1) {
905 sum -= term;
906 if (threshold <= sum) {
907 return 0.25 * proposal;
908 }
909 } else {
910 sum += term;
911 if (threshold >= sum) {
912 break;
913 }
914 }
915 }
916 }
917}
918
919// ── Saddlepoint helpers (math §9) ────────────────────────────────────────
920
921extern "C" __device__ double saddlepoint_t(double x) {
922 if (fabs(x - 1.0) < 1.0e-9) return 0.0;
923 if (x < 1.0) {
924 double v = sqrt(3.0 * (1.0 - x)); if (v < 1.0e-6) v = 1.0e-6;
925 for (int it = 0; it < 6; ++it) {
926 double tanh_v = tanh(v);
927 double f = tanh_v / v - x;
928 double sech_sq = 1.0 - tanh_v * tanh_v;
929 double df = (sech_sq - tanh_v / v) / v;
930 v -= f / df;
931 if (fabs(v) < 1.0e-12) break;
932 }
933 return -0.5 * v * v;
934 } else {
935 double v = sqrt(3.0 * (x - 1.0));
936 if (v > 0.49 * PG_PI) v = 0.49 * PG_PI;
937 if (v < 1.0e-6) v = 1.0e-6;
938 for (int it = 0; it < 6; ++it) {
939 double tan_v = tan(v);
940 double f = tan_v / v - x;
941 double sec_sq = 1.0 + tan_v * tan_v;
942 double df = (sec_sq - tan_v / v) / v;
943 v -= f / df;
944 if (v < 1.0e-6) v = 1.0e-6;
945 if (v > 0.499999 * PG_PI) v = 0.499999 * PG_PI;
946 }
947 return 0.5 * v * v;
948 }
949}
950
951// ── Kernels ──────────────────────────────────────────────────────────────
952
953extern "C" __global__ void pg1_kernel(
954 unsigned long long seed,
955 unsigned int n,
956 const unsigned int* __restrict__ rows, // index map into shapes/tilts/out, length n
957 const double* __restrict__ tilts,
958 double* __restrict__ out)
959{
960 unsigned int slot = blockIdx.x * blockDim.x + threadIdx.x;
961 if (slot >= n) return;
962 unsigned int row = rows[slot];
963 struct XorwowState st;
964 xorwow_seed(&st, seed, (unsigned long long)row);
965 double c = tilts[row];
966 out[row] = pg1_draw(&st, c);
967}
968
969extern "C" __global__ void sp_kernel(
970 unsigned long long seed,
971 unsigned int n,
972 const unsigned int* __restrict__ rows,
973 const unsigned int* __restrict__ shapes,
974 const double* __restrict__ tilts,
975 double* __restrict__ out)
976{
977 unsigned int slot = blockIdx.x * blockDim.x + threadIdx.x;
978 if (slot >= n) return;
979 unsigned int row = rows[slot];
980 struct XorwowState st;
981 xorwow_seed(&st, seed, (unsigned long long)row);
982 unsigned int b = shapes[row];
983 double c = tilts[row];
984 // Convolution-equivalent device fallback: sum b PG(1, c) draws. This
985 // is correct in distribution; the *true* saddlepoint envelope ships
986 // with phase 3 hill-climb. Until then, the kernel is callable and
987 // produces draws that pass the §12 KS test — the only thing the
988 // saddlepoint is supposed to buy is throughput at large b.
989 double acc = 0.0;
990 for (unsigned int j = 0; j < b; ++j) {
991 acc += pg1_draw(&st, c);
992 }
993 // Touch saddlepoint_t so the helper isn’t DCE’d before phase 3 wiring;
994 // the value is unused (multiplied by zero) so this is free.
995 double sp_warm = saddlepoint_t(0.5);
996 out[row] = acc + 0.0 * sp_warm;
997}
998
999extern "C" __global__ void normal_kernel(
1000 unsigned long long seed,
1001 unsigned int n,
1002 const unsigned int* __restrict__ rows,
1003 const unsigned int* __restrict__ shapes,
1004 const double* __restrict__ tilts,
1005 double* __restrict__ out)
1006{
1007 unsigned int slot = blockIdx.x * blockDim.x + threadIdx.x;
1008 if (slot >= n) return;
1009 unsigned int row = rows[slot];
1010 struct XorwowState st;
1011 xorwow_seed(&st, seed, (unsigned long long)row);
1012 double b = (double)shapes[row];
1013 double c = fabs(tilts[row]);
1014 double mean;
1015 double var;
1016 if (c < 1.0e-8) {
1017 mean = 0.25 * b;
1018 var = b / 24.0;
1019 } else {
1020 mean = b * tanh(0.5 * c) / (2.0 * c);
1021 // (sinh c - c)/(1 + cosh c) == tanh(c/2) - c/(1 + cosh c): stable when
1022 // cosh overflows (tanh saturates, second term -> 0), unlike the raw
1023 // form's inf/inf = NaN. Matches the Rust pg_variance helper.
1024 double ratio = tanh(0.5 * c) - c / (1.0 + cosh(c));
1025 var = b * ratio / (2.0 * c * c * c);
1026 }
1027 double sd = sqrt(var);
1028 double draw = mean + sd * xorwow_norm(&st);
1029 if (draw <= 0.0) draw = -draw + 1.0e-300;
1030 out[row] = draw;
1031}
1032"#;
1033
1034 const THREADS_PER_BLOCK: u32 = 128;
1035
1036 pub(super) fn ptx_source() -> String {
1039 let mut src = String::with_capacity(PTX_SOURCE_PRELUDE.len() + PTX_SOURCE_BODY.len() + 256);
1040 src.push_str(PTX_SOURCE_PRELUDE);
1041 src.push_str(
1042 "\n// ── Devroye PG(1, c) constants (derived by the Rust host) ────────────\n",
1043 );
1044 src.push_str(&render_cuda_devroye_constants());
1045 src.push_str(PTX_SOURCE_BODY);
1046 src
1047 }
1048
1049 fn module(ctx: &Arc<CudaContext>) -> Result<&'static Arc<CudaModule>, GpuError> {
1050 static CACHE: gam_gpu::device_cache::PtxModuleCache =
1051 gam_gpu::device_cache::PtxModuleCache::new();
1052 CACHE.get_or_compile(ctx, "polya_gamma", &ptx_source())
1053 }
1054
1055 pub(super) fn draw_batch_gpu(
1056 input: &PolyaGammaBatchInput<'_>,
1057 ) -> Result<Array1<f64>, GpuError> {
1058 let n = input.rows();
1059 if n == 0 {
1060 return Ok(Array1::<f64>::zeros(0));
1061 }
1062 let (ctx, stream) =
1063 context_and_stream().map_err(|reason| GpuError::DriverCallFailed { reason })?;
1064 let compiled = module(&ctx)?;
1065 let module_handle: &Arc<CudaModule> = compiled;
1066
1067 let mut pg1_rows: Vec<u32> = Vec::new();
1073 let mut sp_rows: Vec<u32> = Vec::new();
1074 let mut normal_rows: Vec<u32> = Vec::new();
1075 let mut host_rows: Vec<u32> = Vec::new();
1076 for (i, &b) in input.shapes.iter().enumerate() {
1077 let idx = i as u32;
1078 if b <= PG1_MAX_B {
1079 pg1_rows.push(idx);
1080 } else if b < SADDLE_MIN_B {
1081 host_rows.push(idx);
1082 } else if b <= SADDLE_MAX_B {
1083 sp_rows.push(idx);
1084 } else {
1085 normal_rows.push(idx);
1086 }
1087 }
1088
1089 let tilts_vec: Vec<f64> = match input.tilts.as_slice() {
1092 Some(s) => s.to_vec(),
1093 None => input.tilts.iter().copied().collect(),
1094 };
1095 let shapes_vec: Vec<u32> = match input.shapes.as_slice() {
1096 Some(s) => s.to_vec(),
1097 None => input.shapes.iter().copied().collect(),
1098 };
1099 let tilts_dev = stream
1100 .clone_htod(&tilts_vec)
1101 .gpu_ctx("polya_gamma upload tilts")?;
1102 let shapes_dev = stream
1103 .clone_htod(&shapes_vec)
1104 .gpu_ctx("polya_gamma upload shapes")?;
1105 let mut out_dev = stream
1106 .alloc_zeros::<f64>(n)
1107 .gpu_ctx("polya_gamma alloc out")?;
1108
1109 if !pg1_rows.is_empty() {
1111 let rows_dev = stream
1112 .clone_htod(&pg1_rows)
1113 .gpu_ctx("polya_gamma upload pg1 rows")?;
1114 launch_pg1(
1115 &stream,
1116 module_handle,
1117 input.seed,
1118 &rows_dev,
1119 &tilts_dev,
1120 &mut out_dev,
1121 )?;
1122 }
1123 if !sp_rows.is_empty() {
1124 let rows_dev = stream
1125 .clone_htod(&sp_rows)
1126 .gpu_ctx("polya_gamma upload sp rows")?;
1127 launch_sp(
1128 &stream,
1129 module_handle,
1130 input.seed,
1131 &rows_dev,
1132 &shapes_dev,
1133 &tilts_dev,
1134 &mut out_dev,
1135 )?;
1136 }
1137 if !normal_rows.is_empty() {
1138 let rows_dev = stream
1139 .clone_htod(&normal_rows)
1140 .gpu_ctx("polya_gamma upload normal rows")?;
1141 launch_normal(
1142 &stream,
1143 module_handle,
1144 input.seed,
1145 &rows_dev,
1146 &shapes_dev,
1147 &tilts_dev,
1148 &mut out_dev,
1149 )?;
1150 }
1151
1152 let mut out_host = stream
1154 .clone_dtoh(&out_dev)
1155 .gpu_ctx("polya_gamma download out")?;
1156 for &row in &host_rows {
1157 let i = row as usize;
1158 let mut st = XorwowState::new(input.seed.0, row as u64);
1159 let b = input.shapes[i];
1160 let c = input.tilts[i];
1161 out_host[i] = if b <= SADDLE_MAX_B {
1162 pg_convolution_cpu_oracle(&mut st, b, c)
1163 } else {
1164 pg_normal_cpu_oracle(&mut st, b, c)
1167 };
1168 }
1169 Ok(Array1::from_vec(out_host))
1170 }
1171
1172 fn expect_untimed_launch(
1179 timing_events: Option<(cudarc::driver::CudaEvent, cudarc::driver::CudaEvent)>,
1180 kernel: &str,
1181 ) -> Result<(), GpuError> {
1182 if timing_events.is_some() {
1183 return Err(GpuError::DriverCallFailed {
1184 reason: format!(
1185 "polya_gamma launch {kernel}: the driver returned a timing event pair for a \
1186 launch configured without timing flags"
1187 ),
1188 });
1189 }
1190 Ok(())
1191 }
1192
1193 fn launch_pg1(
1194 stream: &Arc<CudaStream>,
1195 module: &Arc<CudaModule>,
1196 seed: PgSeed,
1197 rows: &cudarc::driver::CudaSlice<u32>,
1198 tilts: &cudarc::driver::CudaSlice<f64>,
1199 out: &mut cudarc::driver::CudaSlice<f64>,
1200 ) -> Result<(), GpuError> {
1201 let func = module
1202 .load_function("pg1_kernel")
1203 .gpu_ctx("polya_gamma load pg1_kernel")?;
1204 let n = rows.len() as u32;
1205 let grid = (n + THREADS_PER_BLOCK - 1) / THREADS_PER_BLOCK;
1206 let cfg = LaunchConfig {
1207 grid_dim: (grid, 1, 1),
1208 block_dim: (THREADS_PER_BLOCK, 1, 1),
1209 shared_mem_bytes: 0,
1210 };
1211 let seed_arg: u64 = seed.0;
1212 unsafe {
1215 stream
1216 .launch_builder(&func)
1217 .arg(&seed_arg)
1218 .arg(&n)
1219 .arg(rows)
1220 .arg(tilts)
1221 .arg(out)
1222 .launch(cfg)
1223 }
1224 .gpu_ctx("polya_gamma launch pg1_kernel")
1225 .and_then(|timing_events| expect_untimed_launch(timing_events, "pg1_kernel"))
1226 }
1227
1228 fn launch_sp(
1229 stream: &Arc<CudaStream>,
1230 module: &Arc<CudaModule>,
1231 seed: PgSeed,
1232 rows: &cudarc::driver::CudaSlice<u32>,
1233 shapes: &cudarc::driver::CudaSlice<u32>,
1234 tilts: &cudarc::driver::CudaSlice<f64>,
1235 out: &mut cudarc::driver::CudaSlice<f64>,
1236 ) -> Result<(), GpuError> {
1237 let func = module
1238 .load_function("sp_kernel")
1239 .gpu_ctx("polya_gamma load sp_kernel")?;
1240 let n = rows.len() as u32;
1241 let grid = (n + THREADS_PER_BLOCK - 1) / THREADS_PER_BLOCK;
1242 let cfg = LaunchConfig {
1243 grid_dim: (grid, 1, 1),
1244 block_dim: (THREADS_PER_BLOCK, 1, 1),
1245 shared_mem_bytes: 0,
1246 };
1247 let seed_arg: u64 = seed.0;
1248 unsafe {
1251 stream
1252 .launch_builder(&func)
1253 .arg(&seed_arg)
1254 .arg(&n)
1255 .arg(rows)
1256 .arg(shapes)
1257 .arg(tilts)
1258 .arg(out)
1259 .launch(cfg)
1260 }
1261 .gpu_ctx("polya_gamma launch sp_kernel")
1262 .and_then(|timing_events| expect_untimed_launch(timing_events, "sp_kernel"))
1263 }
1264
1265 fn launch_normal(
1266 stream: &Arc<CudaStream>,
1267 module: &Arc<CudaModule>,
1268 seed: PgSeed,
1269 rows: &cudarc::driver::CudaSlice<u32>,
1270 shapes: &cudarc::driver::CudaSlice<u32>,
1271 tilts: &cudarc::driver::CudaSlice<f64>,
1272 out: &mut cudarc::driver::CudaSlice<f64>,
1273 ) -> Result<(), GpuError> {
1274 let func = module
1275 .load_function("normal_kernel")
1276 .gpu_ctx("polya_gamma load normal_kernel")?;
1277 let n = rows.len() as u32;
1278 let grid = (n + THREADS_PER_BLOCK - 1) / THREADS_PER_BLOCK;
1279 let cfg = LaunchConfig {
1280 grid_dim: (grid, 1, 1),
1281 block_dim: (THREADS_PER_BLOCK, 1, 1),
1282 shared_mem_bytes: 0,
1283 };
1284 let seed_arg: u64 = seed.0;
1285 unsafe {
1287 stream
1288 .launch_builder(&func)
1289 .arg(&seed_arg)
1290 .arg(&n)
1291 .arg(rows)
1292 .arg(shapes)
1293 .arg(tilts)
1294 .arg(out)
1295 .launch(cfg)
1296 }
1297 .gpu_ctx("polya_gamma launch normal_kernel")
1298 .and_then(|timing_events| expect_untimed_launch(timing_events, "normal_kernel"))
1299 }
1300}
1301
1302#[cfg(test)]
1307mod tests {
1308 use super::*;
1309
1310 #[cfg(target_os = "linux")]
1311 fn cuda_runtime_for_test(
1312 test_name: &str,
1313 ) -> Option<&'static gam_gpu::device_runtime::GpuRuntime> {
1314 match gam_gpu::device_runtime::GpuRuntime::resolve(gam_gpu::GpuPolicy::Auto) {
1315 Ok(Some(runtime)) => Some(runtime),
1316 Ok(None) => {
1317 eprintln!("[{test_name}] no CUDA device on host — skipping");
1318 None
1319 }
1320 Err(error) => panic!("[{test_name}] CUDA probe failed: {error}"),
1321 }
1322 }
1323
1324 #[cfg(target_os = "linux")]
1331 fn assert_draw_batch_declines_to_cpu(
1332 shapes: &Array1<u32>,
1333 tilts: &Array1<f64>,
1334 seed: PgSeed,
1335 ) -> Array1<f64> {
1336 let dispatched = draw_batch(PolyaGammaBatchInput {
1337 shapes: shapes.view(),
1338 tilts: tilts.view(),
1339 seed,
1340 })
1341 .expect("the production PG draw entry must succeed on every host");
1342 let cpu = draw_batch_cpu(&PolyaGammaBatchInput {
1343 shapes: shapes.view(),
1344 tilts: tilts.view(),
1345 seed,
1346 })
1347 .expect("CPU PG draw");
1348 assert_eq!(dispatched.len(), cpu.len());
1349 for (i, (a, b)) in dispatched.iter().zip(cpu.iter()).enumerate() {
1350 assert_eq!(
1351 a.to_bits(),
1352 b.to_bits(),
1353 "row {i}: no CUDA runtime on this host, yet the production PG dispatcher did \
1354 not return the CPU path's draw bit-for-bit"
1355 );
1356 }
1357 dispatched
1358 }
1359
1360 #[test]
1366 fn sub_crossover_batch_routes_to_cpu_bitwise_on_every_host() {
1367 const N: usize = 16;
1368 assert!(
1369 N < gam_gpu::policy::GpuDispatchPolicy::MIN_CALIBRATABLE_FUSED_KERNEL_N,
1370 "the fixture must remain below every reachable fused-kernel crossover"
1371 );
1372 let shapes = Array1::from_iter((0..N).map(|i| 1 + (i % 4) as u32));
1373 let tilts = Array1::from_iter((0..N).map(|i| (i as f64 - 7.5) / 3.0));
1374 let seed = PgSeed(0x2504_2504_2504_2504);
1375 let dispatched = draw_batch(PolyaGammaBatchInput {
1376 shapes: shapes.view(),
1377 tilts: tilts.view(),
1378 seed,
1379 })
1380 .expect("the production PG dispatcher must accept the small batch");
1381 let cpu = draw_batch_cpu(&PolyaGammaBatchInput {
1382 shapes: shapes.view(),
1383 tilts: tilts.view(),
1384 seed,
1385 })
1386 .expect("the CPU PG oracle must accept the small batch");
1387
1388 assert_eq!(dispatched.len(), cpu.len());
1389 for (row, (actual, expected)) in dispatched.iter().zip(cpu.iter()).enumerate() {
1390 assert_eq!(
1391 actual.to_bits(),
1392 expected.to_bits(),
1393 "row {row}: a sub-crossover batch did not use the deterministic CPU path"
1394 );
1395 }
1396 }
1397
1398 #[cfg(target_os = "linux")]
1420 fn assert_dispatch_worthy_and_report(
1421 label: &str,
1422 policy: &gam_gpu::policy::GpuDispatchPolicy,
1423 n: usize,
1424 dt_cpu: f64,
1425 dt_gpu: f64,
1426 ) {
1427 let speedup = dt_cpu / dt_gpu;
1428 println!(
1429 "{label}: n={n} cpu={dt_cpu:.3}s gpu={dt_gpu:.3}s speedup={speedup:.1}× \
1430 (perf record; the gate is the policy decision below)"
1431 );
1432 assert!(
1433 policy.polya_gamma_batch_target_is_gpu(n),
1434 "{label}: n={n} rows is below this device's calibrated fused-kernel \
1435 crossover ({}), so the fixture no longer exercises a shape the \
1436 dispatch policy would send to the device — grow the fixture rather \
1437 than lowering the crossover",
1438 policy.fused_kernel_min_n
1439 );
1440 assert!(
1441 !policy.polya_gamma_batch_target_is_gpu(0),
1442 "{label}: the dispatch predicate admitted an empty batch, so the \
1443 assertion above proves nothing about n={n}"
1444 );
1445 }
1446
1447 fn assert_pg_batch_mean_matches_theory(
1454 draws: &Array1<f64>,
1455 shapes: &Array1<u32>,
1456 tilts: &Array1<f64>,
1457 label: &str,
1458 ) {
1459 let n = draws.len();
1460 assert!(n > 0, "{label}: empty PG batch");
1461 let empirical = draws.iter().sum::<f64>() / n as f64;
1462 let theory = (0..n)
1463 .map(|i| pg_mean(f64::from(shapes[i]), tilts[i]))
1464 .sum::<f64>()
1465 / n as f64;
1466 let sigma = ((0..n)
1467 .map(|i| pg_variance(f64::from(shapes[i]), tilts[i]))
1468 .sum::<f64>())
1469 .sqrt()
1470 / n as f64;
1471 let band = 6.0 * sigma;
1472 assert!(
1473 (empirical - theory).abs() <= band,
1474 "{label}: PG batch mean {empirical:.6e} departs from theory {theory:.6e} by \
1475 {:.3e} (6σ band {band:.3e}, n={n})",
1476 (empirical - theory).abs()
1477 );
1478 }
1479
1480 #[test]
1497 fn pg_batch_mean_matches_theory_on_every_host() {
1498 let n = 20_000usize;
1501 let shapes = Array1::<u32>::from_shape_fn(n, |i| 1 + (i % 4) as u32);
1502 let tilts = Array1::<f64>::from_shape_fn(n, |i| ((i as f64) / (n as f64)) * 6.0 - 3.0);
1503 let seed = PgSeed(0x9E_37_79_B9_7F_4A_7C_15);
1504
1505 let draws = draw_batch(PolyaGammaBatchInput {
1506 shapes: shapes.view(),
1507 tilts: tilts.view(),
1508 seed,
1509 })
1510 .expect("the production PG draw entry must succeed on every host");
1511
1512 assert_eq!(draws.len(), n, "production PG entry returned a short batch");
1513 assert!(
1514 draws.iter().all(|d| d.is_finite() && *d > 0.0),
1515 "a Polya-Gamma draw is supported on (0, inf); the batch contains a \
1516 non-positive or non-finite value"
1517 );
1518 assert_pg_batch_mean_matches_theory(&draws, &shapes, &tilts, "production entry");
1519 }
1520
1521 fn theoretical_mean(b: f64, c: f64) -> f64 {
1522 pg_mean(b, c)
1523 }
1524
1525 fn theoretical_variance(b: f64, c: f64) -> f64 {
1526 pg_variance(b, c)
1527 }
1528
1529 #[test]
1530 fn pg1_cpu_oracle_matches_devroye_mean() {
1531 let n = 25_000;
1535 for &(c, tol) in &[(0.0_f64, 0.05), (1.0, 0.10), (3.0, 0.10)] {
1536 let mut sum = 0.0;
1537 for i in 0..n {
1538 let mut st = XorwowState::new(0xC0FFEE_u64, i as u64);
1539 sum += pg1_draw_cpu_oracle(&mut st, c);
1540 }
1541 let emp = sum / n as f64;
1542 let th = theoretical_mean(1.0, c);
1543 let rel = (emp - th).abs() / th.max(1e-12);
1544 assert!(
1545 rel < tol,
1546 "PG(1,{c}) XORWOW oracle: emp {emp}, theory {th}, rel {rel}"
1547 );
1548 }
1549 }
1550
1551 #[test]
1552 fn pg1_cpu_oracle_variance_matches_theory() {
1553 let n = 100_000;
1554 for &c in &[0.0_f64, 0.5, 2.0, 5.0] {
1555 let mut sum = 0.0;
1556 let mut sum_sq = 0.0;
1557 for i in 0..n {
1558 let mut st = XorwowState::new(0xDEADBEEF_u64, i as u64);
1559 let x = pg1_draw_cpu_oracle(&mut st, c);
1560 sum += x;
1561 sum_sq += x * x;
1562 }
1563 let mean = sum / n as f64;
1564 let var = sum_sq / n as f64 - mean * mean;
1565 let th_var = theoretical_variance(1.0, c);
1566 let rel = (var - th_var).abs() / th_var.max(1e-12);
1567 assert!(
1568 rel < 0.05,
1569 "PG(1,{c}) var: emp {var}, theory {th_var}, rel {rel}"
1570 );
1571 }
1572 }
1573
1574 #[test]
1575 fn xorwow_seeding_is_deterministic() {
1576 let mut a = XorwowState::new(42, 7);
1577 let mut b = XorwowState::new(42, 7);
1578 for _ in 0..1024 {
1579 assert_eq!(a.next_u32(), b.next_u32());
1580 }
1581 let mut c = XorwowState::new(42, 8);
1582 let same = (0..32).all(|_| a.next_u32() == c.next_u32());
1583 assert!(!same, "different rows must produce different streams");
1584 }
1585
1586 #[test]
1587 fn xorwow_unit_in_open_zero_closed_one() {
1588 let mut st = XorwowState::new(123, 0);
1589 for _ in 0..10_000 {
1590 let u = st.next_unit();
1591 assert!(u > 0.0 && u <= 1.0, "u={u} outside (0,1]");
1592 }
1593 }
1594
1595 #[test]
1596 fn saddlepoint_solve_round_trips() {
1597 for &x in &[0.05_f64, 0.3, 0.7, 0.99, 1.01, 1.5, 3.0, 8.0] {
1600 let t = saddlepoint_solve(x);
1601 let kp = if t.abs() < 1e-14 {
1602 1.0
1603 } else if t < 0.0 {
1604 let v = (-2.0 * t).sqrt();
1605 v.tanh() / v
1606 } else {
1607 let v = (2.0 * t).sqrt();
1608 v.tan() / v
1609 };
1610 let rel = (kp - x).abs() / x.max(1e-12);
1611 assert!(
1612 rel < 1e-6,
1613 "saddlepoint_solve(x={x}) → t={t}; K'(t)={kp}, rel={rel}"
1614 );
1615 }
1616 }
1617
1618 #[test]
1619 fn saddlepoint_kpp_is_positive() {
1620 for &t in &[-2.0_f64, -0.5, -1e-5, 0.0, 1e-5, 0.5, 1.0] {
1622 let v = saddlepoint_kpp(t);
1623 assert!(v.is_finite() && v > 0.0, "K''({t}) = {v}");
1624 }
1625 }
1626
1627 #[test]
1628 fn pg_normal_oracle_matches_moments_at_large_b() {
1629 let b = 500u32;
1632 let c = 1.0_f64;
1633 let n = 100_000;
1634 let mut sum = 0.0;
1635 let mut sum_sq = 0.0;
1636 for i in 0..n {
1637 let mut st = XorwowState::new(0xBEEF_u64, i as u64);
1638 let x = pg_normal_cpu_oracle(&mut st, b, c);
1639 sum += x;
1640 sum_sq += x * x;
1641 }
1642 let mean = sum / n as f64;
1643 let var = sum_sq / n as f64 - mean * mean;
1644 let th_mean = theoretical_mean(b as f64, c);
1645 let th_var = theoretical_variance(b as f64, c);
1646 let m_rel = (mean - th_mean).abs() / th_mean;
1647 let v_rel = (var - th_var).abs() / th_var;
1648 assert!(
1649 m_rel < 0.02,
1650 "normal oracle mean: emp {mean}, theory {th_mean}, rel {m_rel}"
1651 );
1652 assert!(
1653 v_rel < 0.05,
1654 "normal oracle var: emp {var}, theory {th_var}, rel {v_rel}"
1655 );
1656 }
1657
1658 #[test]
1659 fn batch_dispatch_selects_every_declared_regime_at_its_boundaries() {
1660 let cases = [
1661 (PG1_MAX_B, -0.75, PolyaGammaCpuRegime::ExactPg1),
1662 (PG1_MAX_B + 1, 0.25, PolyaGammaCpuRegime::ExactConvolution),
1663 (
1664 SADDLE_MIN_B - 1,
1665 1.25,
1666 PolyaGammaCpuRegime::ExactConvolution,
1667 ),
1668 (SADDLE_MIN_B, -1.75, PolyaGammaCpuRegime::Saddlepoint),
1669 (SADDLE_MAX_B, 2.25, PolyaGammaCpuRegime::Saddlepoint),
1670 (NORMAL_MIN_B, -0.5, PolyaGammaCpuRegime::NormalApproximation),
1671 ];
1672 let shapes = Array1::from_vec(cases.iter().map(|case| case.0).collect());
1673 let tilts = Array1::from_vec(cases.iter().map(|case| case.1).collect());
1674 let seed = PgSeed(42);
1675 let input = PolyaGammaBatchInput {
1676 shapes: shapes.view(),
1677 tilts: tilts.view(),
1678 seed,
1679 };
1680 let out = draw_batch_cpu(&input).expect("CPU dispatch");
1681 assert_eq!(out.len(), cases.len());
1682
1683 for (row, &(shape, tilt, expected_regime)) in cases.iter().enumerate() {
1684 assert_eq!(
1685 cpu_regime_for_shape(shape),
1686 expected_regime,
1687 "shape {shape} crossed the wrong declared regime boundary"
1688 );
1689 let mut state = XorwowState::new(seed.0, row as u64);
1690 let expected = match expected_regime {
1691 PolyaGammaCpuRegime::ExactPg1 => pg1_draw_cpu_oracle(&mut state, tilt),
1692 PolyaGammaCpuRegime::ExactConvolution => {
1693 pg_convolution_cpu_oracle(&mut state, shape, tilt)
1694 }
1695 PolyaGammaCpuRegime::Saddlepoint => {
1696 pg_saddlepoint_cpu_oracle(&mut state, shape, tilt)
1697 }
1698 PolyaGammaCpuRegime::NormalApproximation => {
1699 pg_normal_cpu_oracle(&mut state, shape, tilt)
1700 }
1701 };
1702 assert_eq!(
1703 out[row].to_bits(),
1704 expected.to_bits(),
1705 "row {row}, shape {shape}: batch dispatcher did not call {expected_regime:?}"
1706 );
1707 }
1708 }
1709
1710 fn ks_two_sample(a: &mut [f64], b: &mut [f64]) -> f64 {
1719 a.sort_by(|x, y| x.partial_cmp(y).unwrap());
1720 b.sort_by(|x, y| x.partial_cmp(y).unwrap());
1721 let (na, nb) = (a.len() as f64, b.len() as f64);
1722 let (mut i, mut j) = (0usize, 0usize);
1723 let (mut fa, mut fb) = (0.0_f64, 0.0_f64);
1724 let mut d_max = 0.0_f64;
1725 while i < a.len() && j < b.len() {
1726 if a[i] <= b[j] {
1727 i += 1;
1728 fa = i as f64 / na;
1729 } else {
1730 j += 1;
1731 fb = j as f64 / nb;
1732 }
1733 let d = (fa - fb).abs();
1734 if d > d_max {
1735 d_max = d;
1736 }
1737 }
1738 d_max
1739 }
1740
1741 fn ks_critical_001(n_a: usize, n_b: usize) -> f64 {
1746 let na = n_a as f64;
1747 let nb = n_b as f64;
1748 1.6276 * ((na + nb) / (na * nb)).sqrt()
1749 }
1750
1751 #[test]
1752 fn pg1_cpu_oracle_matches_inference_module_distribution() {
1753 use crate::polya_gamma::PolyaGamma;
1759 use rand::{SeedableRng, rngs::StdRng};
1760 let pg = PolyaGamma::new();
1761 for &c in &[0.0_f64, 1.5, 4.0] {
1762 let n_dev = 5_000;
1763 let n_ref = 5_000;
1764 let mut from_oracle: Vec<f64> = (0..n_dev)
1765 .map(|i| {
1766 let mut st = XorwowState::new(0xDEADBEEF_u64 ^ c.to_bits(), i as u64);
1767 pg1_draw_cpu_oracle(&mut st, c)
1768 })
1769 .collect();
1770 let mut from_reference: Vec<f64> = {
1771 let mut rng = StdRng::seed_from_u64(0xABCD_u64 ^ c.to_bits());
1772 (0..n_ref).map(|_| pg.draw(&mut rng, c)).collect()
1773 };
1774 let d = ks_two_sample(&mut from_oracle, &mut from_reference);
1775 let crit = ks_critical_001(n_dev, n_ref);
1776 assert!(
1777 d <= 2.0 * crit,
1778 "PG(1, c={c}) two-sample KS d={d} > 2·crit={}; XORWOW oracle and reference disagree in distribution",
1779 2.0 * crit
1780 );
1781 }
1782 }
1783
1784 #[test]
1789 fn pg1_cpu_oracle_matches_exact_untilted_cdf() {
1790 let sample_count = 20_000usize;
1791 let mut samples: Vec<f64> = (0..sample_count)
1792 .map(|i| {
1793 let mut st = XorwowState::new(0x2320_C0DE, i as u64);
1794 pg1_draw_cpu_oracle(&mut st, 0.0)
1795 })
1796 .collect();
1797 samples.sort_by(f64::total_cmp);
1798
1799 let n = sample_count as f64;
1800 let statistic = samples
1801 .iter()
1802 .enumerate()
1803 .map(|(i, &sample)| {
1804 let cdf = crate::polya_gamma::pg1_untilted_cdf(sample);
1805 let empirical_below = i as f64 / n;
1806 let empirical_through = (i + 1) as f64 / n;
1807 (cdf - empirical_below)
1808 .abs()
1809 .max((empirical_through - cdf).abs())
1810 })
1811 .fold(0.0_f64, f64::max);
1812
1813 let false_rejection_probability = 1e-6_f64;
1816 let critical = (-(false_rejection_probability / 2.0).ln() / (2.0 * n)).sqrt();
1817 assert!(
1818 statistic <= critical,
1819 "CPU exact-PG(1,0) oracle KS statistic {statistic} exceeds DKW critical value {critical}",
1820 );
1821 }
1822
1823 #[test]
1824 fn pg_convolution_identity_at_small_b() {
1825 let n = 4_000;
1830 let b: u32 = 8;
1831 let c: f64 = 1.2;
1832 let mut left: Vec<f64> = (0..n)
1833 .map(|i| {
1834 let mut st = XorwowState::new(0x1111_u64, i as u64);
1837 (0..b).map(|_| pg1_draw_cpu_oracle(&mut st, c)).sum()
1838 })
1839 .collect();
1840 let mut right: Vec<f64> = (0..n)
1841 .map(|i| {
1842 (0..b)
1846 .map(|j| {
1847 let mut st = XorwowState::new(0x2222_u64 ^ (j as u64), i as u64);
1848 pg1_draw_cpu_oracle(&mut st, c)
1849 })
1850 .sum::<f64>()
1851 })
1852 .collect();
1853 let d = ks_two_sample(&mut left, &mut right);
1854 let crit = ks_critical_001(n, n);
1855 assert!(
1856 d <= 2.0 * crit,
1857 "PG({b}, {c}) convolution identity KS d={d} > 2·crit={}",
1858 2.0 * crit
1859 );
1860 }
1861
1862 #[test]
1863 fn pg_normal_kernel_matches_moments_at_b_500() {
1864 let b = 500u32;
1870 let c = 2.0_f64;
1871 let n = 50_000;
1872 let mut sum = 0.0;
1873 let mut sum_sq = 0.0;
1874 for i in 0..n {
1875 let mut st = XorwowState::new(0xCAFE_u64, i as u64);
1876 let x = pg_normal_cpu_oracle(&mut st, b, c);
1877 sum += x;
1878 sum_sq += x * x;
1879 }
1880 let mean = sum / n as f64;
1881 let var = sum_sq / n as f64 - mean * mean;
1882 let th_mean = pg_mean(b as f64, c);
1883 let th_var = pg_variance(b as f64, c);
1884 let m_rel = (mean - th_mean).abs() / th_mean;
1885 let v_rel = (var - th_var).abs() / th_var;
1886 assert!(
1887 m_rel < 0.02,
1888 "normal kernel mean: emp {mean}, theory {th_mean}, rel {m_rel}"
1889 );
1890 assert!(
1891 v_rel < 0.05,
1892 "normal kernel var: emp {var}, theory {th_var}, rel {v_rel}"
1893 );
1894 }
1895
1896 #[test]
1897 fn logistic_gibbs_chain_converges_to_mle_direction() {
1898 use rand::{RngExt, SeedableRng, rngs::StdRng};
1903 let n = 400;
1904 let p = 3;
1905 let beta_star = [1.5_f64, -0.7, 0.3];
1906 let mut design = Array2::<f64>::zeros((n, p));
1907 let mut targets = Array1::<u8>::zeros(n);
1908 let mut rng = StdRng::seed_from_u64(0xFEED);
1909 for i in 0..n {
1910 let x1 = ((i as f64) / (n as f64)) * 2.0 - 1.0;
1911 let x2 = (((i * 13) % n) as f64 / n as f64) * 2.0 - 1.0;
1912 design[[i, 0]] = x1;
1913 design[[i, 1]] = x2;
1914 design[[i, 2]] = 1.0;
1915 let eta = beta_star[0] * x1 + beta_star[1] * x2 + beta_star[2];
1916 let p_y = 1.0 / (1.0 + (-eta).exp());
1917 let u: f64 = rng.random();
1918 targets[i] = if u < p_y { 1 } else { 0 };
1919 }
1920 let q0 = Array2::<f64>::eye(p) * 0.01;
1921 let mut beta = Array1::<f64>::zeros(p);
1922 let mut accum = Array1::<f64>::zeros(p);
1923 let steps = 200;
1924 let burn = 50;
1925 for k in 0..steps {
1926 beta = logistic_gibbs_step(
1927 design.view(),
1928 targets.view(),
1929 q0.view(),
1930 beta.view(),
1931 PgSeed(0xC0DE + k as u64),
1932 0xCAFE + k as u64,
1933 )
1934 .expect("Gibbs step");
1935 if k >= burn {
1936 for j in 0..p {
1937 accum[j] += beta[j];
1938 }
1939 }
1940 }
1941 for j in 0..p {
1942 accum[j] /= (steps - burn) as f64;
1943 }
1944 let dot: f64 = (0..p).map(|j| accum[j] * beta_star[j]).sum();
1945 let na: f64 = accum.iter().map(|v| v * v).sum::<f64>().sqrt();
1946 let nb: f64 = beta_star.iter().map(|v| v * v).sum::<f64>().sqrt();
1947 let cos = dot / (na * nb);
1948 assert!(
1949 cos > 0.85,
1950 "Gibbs chain posterior-mean direction does not align with β*: cos = {cos}, accum = {accum:?}, β* = {beta_star:?}"
1951 );
1952 }
1953
1954 #[test]
1970 #[cfg(target_os = "linux")]
1971 fn polya_gamma_dispatch_worthiness_pg1() {
1972 let n = 200_000usize;
1973 let shapes = Array1::<u32>::from_elem(n, 1);
1974 let mut tilts = Array1::<f64>::zeros(n);
1975 for i in 0..n {
1976 tilts[i] = ((i as f64) / (n as f64)) * 6.0 - 3.0;
1977 }
1978 let seed = PgSeed(0x50_4F_4C_59_47_41_4D_41);
1979
1980 let Some(runtime) = cuda_runtime_for_test("polya_gamma_dispatch_worthiness_pg1") else {
1981 let cpu_draws = assert_draw_batch_declines_to_cpu(&shapes, &tilts, seed);
1987 assert_pg_batch_mean_matches_theory(&cpu_draws, &shapes, &tilts, "pg1 CPU fallback");
1988 return;
1989 };
1990
1991 {
1994 let warm_shapes = Array1::<u32>::from_elem(16, 1);
1995 let warm_tilts = Array1::<f64>::zeros(16);
1996 linux_cuda::draw_batch_gpu(&PolyaGammaBatchInput {
1997 shapes: warm_shapes.view(),
1998 tilts: warm_tilts.view(),
1999 seed,
2000 })
2001 .expect("warm");
2002 }
2003
2004 let t_gpu_start = std::time::Instant::now();
2005 let gpu_draws = linux_cuda::draw_batch_gpu(&PolyaGammaBatchInput {
2006 shapes: shapes.view(),
2007 tilts: tilts.view(),
2008 seed,
2009 })
2010 .expect("GPU draw_batch");
2011 let dt_gpu = t_gpu_start.elapsed().as_secs_f64();
2012
2013 let t_cpu_start = std::time::Instant::now();
2014 let cpu_draws = draw_batch_cpu(&PolyaGammaBatchInput {
2015 shapes: shapes.view(),
2016 tilts: tilts.view(),
2017 seed,
2018 })
2019 .expect("CPU draw_batch");
2020 let dt_cpu = t_cpu_start.elapsed().as_secs_f64();
2021
2022 assert_pg_batch_mean_matches_theory(&gpu_draws, &shapes, &tilts, "pg1 device");
2027 assert_pg_batch_mean_matches_theory(&cpu_draws, &shapes, &tilts, "pg1 CPU baseline");
2028
2029 assert_dispatch_worthy_and_report(
2030 "polya_gamma_hill_climb_pg1",
2031 runtime.policy(),
2032 n,
2033 dt_cpu,
2034 dt_gpu,
2035 );
2036 }
2037
2038 #[test]
2046 #[cfg(target_os = "linux")]
2047 fn polya_gamma_dispatch_worthiness_mixed_nb() {
2048 let n = 200_000usize;
2049 let mut shapes = Array1::<u32>::zeros(n);
2050 let mut tilts = Array1::<f64>::zeros(n);
2051 for i in 0..n {
2052 shapes[i] = if i.is_multiple_of(5) { 1 } else { 250 };
2054 tilts[i] = ((i as f64) / (n as f64)) * 4.0 - 2.0;
2055 }
2056 let seed = PgSeed(0xDEAD_BEEF_CAFE_BABE);
2057
2058 let Some(runtime) = cuda_runtime_for_test("polya_gamma_dispatch_worthiness_mixed_nb")
2059 else {
2060 let cpu_draws = assert_draw_batch_declines_to_cpu(&shapes, &tilts, seed);
2063 assert_pg_batch_mean_matches_theory(
2064 &cpu_draws,
2065 &shapes,
2066 &tilts,
2067 "mixed-NB CPU fallback",
2068 );
2069 return;
2070 };
2071
2072 let warm_shapes = Array1::<u32>::from_elem(16, 250);
2074 let warm_tilts = Array1::<f64>::zeros(16);
2075 linux_cuda::draw_batch_gpu(&PolyaGammaBatchInput {
2076 shapes: warm_shapes.view(),
2077 tilts: warm_tilts.view(),
2078 seed,
2079 })
2080 .expect("warm");
2081
2082 let t_gpu = std::time::Instant::now();
2083 let gpu_draws = linux_cuda::draw_batch_gpu(&PolyaGammaBatchInput {
2084 shapes: shapes.view(),
2085 tilts: tilts.view(),
2086 seed,
2087 })
2088 .expect("GPU mixed");
2089 let dt_gpu = t_gpu.elapsed().as_secs_f64();
2090
2091 let t_cpu = std::time::Instant::now();
2092 let cpu_draws = draw_batch_cpu(&PolyaGammaBatchInput {
2093 shapes: shapes.view(),
2094 tilts: tilts.view(),
2095 seed,
2096 })
2097 .expect("CPU mixed");
2098 let dt_cpu = t_cpu.elapsed().as_secs_f64();
2099
2100 assert_pg_batch_mean_matches_theory(&gpu_draws, &shapes, &tilts, "mixed-NB device");
2104 assert_pg_batch_mean_matches_theory(&cpu_draws, &shapes, &tilts, "mixed-NB CPU baseline");
2105
2106 assert_dispatch_worthy_and_report(
2107 "polya_gamma_hill_climb_mixed",
2108 runtime.policy(),
2109 n,
2110 dt_cpu,
2111 dt_gpu,
2112 );
2113 }
2114
2115 #[test]
2119 #[cfg(target_os = "linux")]
2120 fn pg1_gpu_matches_cpu_oracle_when_runtime_available() {
2121 let on_cuda =
2122 cuda_runtime_for_test("pg1_gpu_matches_cpu_oracle_when_runtime_available").is_some();
2123 let sample_count = 4_096usize;
2124 let shapes = Array1::<u32>::from_elem(sample_count, 1);
2125 for &tilt in &[0.0_f64, 1.5, 4.0] {
2126 let tilts = Array1::<f64>::from_elem(sample_count, tilt);
2127 if !on_cuda {
2128 let cpu_draws = assert_draw_batch_declines_to_cpu(
2134 &shapes,
2135 &tilts,
2136 PgSeed(0x9E37_79B9_7F4A_7C15 ^ tilt.to_bits()),
2137 );
2138 assert_pg_batch_mean_matches_theory(
2139 &cpu_draws,
2140 &shapes,
2141 &tilts,
2142 "pg1 CPU fallback parity",
2143 );
2144 continue;
2145 }
2146 let mut gpu = linux_cuda::draw_batch_gpu(&PolyaGammaBatchInput {
2147 shapes: shapes.view(),
2148 tilts: tilts.view(),
2149 seed: PgSeed(0x9E37_79B9_7F4A_7C15 ^ tilt.to_bits()),
2150 })
2151 .expect("GPU draw_batch")
2152 .to_vec();
2153 let mut cpu = draw_batch_cpu(&PolyaGammaBatchInput {
2154 shapes: shapes.view(),
2155 tilts: tilts.view(),
2156 seed: PgSeed(0xD1B5_4A32_D192_ED03 ^ tilt.to_bits()),
2157 })
2158 .expect("CPU draw_batch")
2159 .to_vec();
2160 let statistic = ks_two_sample(&mut gpu, &mut cpu);
2161 let critical = ks_critical_001(sample_count, sample_count);
2162 assert!(
2163 statistic <= 2.0 * critical,
2164 "PG(1, {tilt}) CUDA/upstream KS statistic {statistic} exceeds {}",
2165 2.0 * critical,
2166 );
2167 }
2168 }
2169
2170 #[test]
2178 #[cfg(target_os = "linux")]
2179 fn cuda_source_uses_rendered_constants_only() {
2180 let rendered = render_cuda_devroye_constants();
2181 let assembled = linux_cuda::ptx_source();
2182 assert!(
2183 assembled.contains(rendered.trim_end()),
2184 "assembled CUDA source does not embed the rendered constant block"
2185 );
2186 let define_count = assembled.matches("#define PG_").count();
2189 let rendered_count = rendered.matches("#define PG_").count();
2190 assert_eq!(
2191 define_count, rendered_count,
2192 "CUDA source has {define_count} `#define PG_` lines but the rendered block has {rendered_count}; a stale hand-typed constant is present"
2193 );
2194 }
2195}