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 launch_pg1(
1173 stream: &Arc<CudaStream>,
1174 module: &Arc<CudaModule>,
1175 seed: PgSeed,
1176 rows: &cudarc::driver::CudaSlice<u32>,
1177 tilts: &cudarc::driver::CudaSlice<f64>,
1178 out: &mut cudarc::driver::CudaSlice<f64>,
1179 ) -> Result<(), GpuError> {
1180 let func = module
1181 .load_function("pg1_kernel")
1182 .gpu_ctx("polya_gamma load pg1_kernel")?;
1183 let n = rows.len() as u32;
1184 let grid = (n + THREADS_PER_BLOCK - 1) / THREADS_PER_BLOCK;
1185 let cfg = LaunchConfig {
1186 grid_dim: (grid, 1, 1),
1187 block_dim: (THREADS_PER_BLOCK, 1, 1),
1188 shared_mem_bytes: 0,
1189 };
1190 let seed_arg: u64 = seed.0;
1191 unsafe {
1194 stream
1195 .launch_builder(&func)
1196 .arg(&seed_arg)
1197 .arg(&n)
1198 .arg(rows)
1199 .arg(tilts)
1200 .arg(out)
1201 .launch(cfg)
1202 }
1203 .map(|_| ())
1204 .gpu_ctx("polya_gamma launch pg1_kernel")
1205 }
1206
1207 fn launch_sp(
1208 stream: &Arc<CudaStream>,
1209 module: &Arc<CudaModule>,
1210 seed: PgSeed,
1211 rows: &cudarc::driver::CudaSlice<u32>,
1212 shapes: &cudarc::driver::CudaSlice<u32>,
1213 tilts: &cudarc::driver::CudaSlice<f64>,
1214 out: &mut cudarc::driver::CudaSlice<f64>,
1215 ) -> Result<(), GpuError> {
1216 let func = module
1217 .load_function("sp_kernel")
1218 .gpu_ctx("polya_gamma load sp_kernel")?;
1219 let n = rows.len() as u32;
1220 let grid = (n + THREADS_PER_BLOCK - 1) / THREADS_PER_BLOCK;
1221 let cfg = LaunchConfig {
1222 grid_dim: (grid, 1, 1),
1223 block_dim: (THREADS_PER_BLOCK, 1, 1),
1224 shared_mem_bytes: 0,
1225 };
1226 let seed_arg: u64 = seed.0;
1227 unsafe {
1230 stream
1231 .launch_builder(&func)
1232 .arg(&seed_arg)
1233 .arg(&n)
1234 .arg(rows)
1235 .arg(shapes)
1236 .arg(tilts)
1237 .arg(out)
1238 .launch(cfg)
1239 }
1240 .map(|_| ())
1241 .gpu_ctx("polya_gamma launch sp_kernel")
1242 }
1243
1244 fn launch_normal(
1245 stream: &Arc<CudaStream>,
1246 module: &Arc<CudaModule>,
1247 seed: PgSeed,
1248 rows: &cudarc::driver::CudaSlice<u32>,
1249 shapes: &cudarc::driver::CudaSlice<u32>,
1250 tilts: &cudarc::driver::CudaSlice<f64>,
1251 out: &mut cudarc::driver::CudaSlice<f64>,
1252 ) -> Result<(), GpuError> {
1253 let func = module
1254 .load_function("normal_kernel")
1255 .gpu_ctx("polya_gamma load normal_kernel")?;
1256 let n = rows.len() as u32;
1257 let grid = (n + THREADS_PER_BLOCK - 1) / THREADS_PER_BLOCK;
1258 let cfg = LaunchConfig {
1259 grid_dim: (grid, 1, 1),
1260 block_dim: (THREADS_PER_BLOCK, 1, 1),
1261 shared_mem_bytes: 0,
1262 };
1263 let seed_arg: u64 = seed.0;
1264 unsafe {
1266 stream
1267 .launch_builder(&func)
1268 .arg(&seed_arg)
1269 .arg(&n)
1270 .arg(rows)
1271 .arg(shapes)
1272 .arg(tilts)
1273 .arg(out)
1274 .launch(cfg)
1275 }
1276 .map(|_| ())
1277 .gpu_ctx("polya_gamma launch normal_kernel")
1278 }
1279}
1280
1281#[cfg(test)]
1286mod tests {
1287 use super::*;
1288
1289 #[cfg(target_os = "linux")]
1290 fn cuda_runtime_for_test(test_name: &str) -> Option<&'static gam_gpu::device_runtime::GpuRuntime> {
1291 match gam_gpu::device_runtime::GpuRuntime::resolve(gam_gpu::GpuPolicy::Auto) {
1292 Ok(Some(runtime)) => Some(runtime),
1293 Ok(None) => {
1294 eprintln!("[{test_name}] no CUDA device on host — skipping");
1295 None
1296 }
1297 Err(error) => panic!("[{test_name}] CUDA probe failed: {error}"),
1298 }
1299 }
1300
1301 #[cfg(target_os = "linux")]
1308 fn assert_draw_batch_declines_to_cpu(
1309 shapes: &Array1<u32>,
1310 tilts: &Array1<f64>,
1311 seed: PgSeed,
1312 ) -> Array1<f64> {
1313 let dispatched = draw_batch(PolyaGammaBatchInput {
1314 shapes: shapes.view(),
1315 tilts: tilts.view(),
1316 seed,
1317 })
1318 .expect("the production PG draw entry must succeed on every host");
1319 let cpu = draw_batch_cpu(&PolyaGammaBatchInput {
1320 shapes: shapes.view(),
1321 tilts: tilts.view(),
1322 seed,
1323 })
1324 .expect("CPU PG draw");
1325 assert_eq!(dispatched.len(), cpu.len());
1326 for (i, (a, b)) in dispatched.iter().zip(cpu.iter()).enumerate() {
1327 assert_eq!(
1328 a.to_bits(),
1329 b.to_bits(),
1330 "row {i}: no CUDA runtime on this host, yet the production PG dispatcher did \
1331 not return the CPU path's draw bit-for-bit"
1332 );
1333 }
1334 dispatched
1335 }
1336
1337 #[test]
1343 fn sub_crossover_batch_routes_to_cpu_bitwise_on_every_host() {
1344 const N: usize = 16;
1345 assert!(
1346 N < gam_gpu::policy::GpuDispatchPolicy::MIN_CALIBRATABLE_FUSED_KERNEL_N,
1347 "the fixture must remain below every reachable fused-kernel crossover"
1348 );
1349 let shapes = Array1::from_iter((0..N).map(|i| 1 + (i % 4) as u32));
1350 let tilts = Array1::from_iter((0..N).map(|i| (i as f64 - 7.5) / 3.0));
1351 let seed = PgSeed(0x2504_2504_2504_2504);
1352 let dispatched = draw_batch(PolyaGammaBatchInput {
1353 shapes: shapes.view(),
1354 tilts: tilts.view(),
1355 seed,
1356 })
1357 .expect("the production PG dispatcher must accept the small batch");
1358 let cpu = draw_batch_cpu(&PolyaGammaBatchInput {
1359 shapes: shapes.view(),
1360 tilts: tilts.view(),
1361 seed,
1362 })
1363 .expect("the CPU PG oracle must accept the small batch");
1364
1365 assert_eq!(dispatched.len(), cpu.len());
1366 for (row, (actual, expected)) in dispatched.iter().zip(cpu.iter()).enumerate() {
1367 assert_eq!(
1368 actual.to_bits(),
1369 expected.to_bits(),
1370 "row {row}: a sub-crossover batch did not use the deterministic CPU path"
1371 );
1372 }
1373 }
1374
1375 #[cfg(target_os = "linux")]
1397 fn assert_dispatch_worthy_and_report(
1398 label: &str,
1399 policy: &gam_gpu::policy::GpuDispatchPolicy,
1400 n: usize,
1401 dt_cpu: f64,
1402 dt_gpu: f64,
1403 ) {
1404 let speedup = dt_cpu / dt_gpu;
1405 println!(
1406 "{label}: n={n} cpu={dt_cpu:.3}s gpu={dt_gpu:.3}s speedup={speedup:.1}× \
1407 (perf record; the gate is the policy decision below)"
1408 );
1409 assert!(
1410 policy.polya_gamma_batch_target_is_gpu(n),
1411 "{label}: n={n} rows is below this device's calibrated fused-kernel \
1412 crossover ({}), so the fixture no longer exercises a shape the \
1413 dispatch policy would send to the device — grow the fixture rather \
1414 than lowering the crossover",
1415 policy.fused_kernel_min_n
1416 );
1417 assert!(
1418 !policy.polya_gamma_batch_target_is_gpu(0),
1419 "{label}: the dispatch predicate admitted an empty batch, so the \
1420 assertion above proves nothing about n={n}"
1421 );
1422 }
1423
1424 fn assert_pg_batch_mean_matches_theory(
1431 draws: &Array1<f64>,
1432 shapes: &Array1<u32>,
1433 tilts: &Array1<f64>,
1434 label: &str,
1435 ) {
1436 let n = draws.len();
1437 assert!(n > 0, "{label}: empty PG batch");
1438 let empirical = draws.iter().sum::<f64>() / n as f64;
1439 let theory = (0..n)
1440 .map(|i| pg_mean(f64::from(shapes[i]), tilts[i]))
1441 .sum::<f64>()
1442 / n as f64;
1443 let sigma = ((0..n)
1444 .map(|i| pg_variance(f64::from(shapes[i]), tilts[i]))
1445 .sum::<f64>())
1446 .sqrt()
1447 / n as f64;
1448 let band = 6.0 * sigma;
1449 assert!(
1450 (empirical - theory).abs() <= band,
1451 "{label}: PG batch mean {empirical:.6e} departs from theory {theory:.6e} by \
1452 {:.3e} (6σ band {band:.3e}, n={n})",
1453 (empirical - theory).abs()
1454 );
1455 }
1456
1457 #[test]
1474 fn pg_batch_mean_matches_theory_on_every_host() {
1475 let n = 20_000usize;
1478 let shapes = Array1::<u32>::from_shape_fn(n, |i| 1 + (i % 4) as u32);
1479 let tilts = Array1::<f64>::from_shape_fn(n, |i| ((i as f64) / (n as f64)) * 6.0 - 3.0);
1480 let seed = PgSeed(0x9E_37_79_B9_7F_4A_7C_15);
1481
1482 let draws = draw_batch(PolyaGammaBatchInput {
1483 shapes: shapes.view(),
1484 tilts: tilts.view(),
1485 seed,
1486 })
1487 .expect("the production PG draw entry must succeed on every host");
1488
1489 assert_eq!(draws.len(), n, "production PG entry returned a short batch");
1490 assert!(
1491 draws.iter().all(|d| d.is_finite() && *d > 0.0),
1492 "a Polya-Gamma draw is supported on (0, inf); the batch contains a \
1493 non-positive or non-finite value"
1494 );
1495 assert_pg_batch_mean_matches_theory(&draws, &shapes, &tilts, "production entry");
1496 }
1497
1498 fn theoretical_mean(b: f64, c: f64) -> f64 {
1499 pg_mean(b, c)
1500 }
1501
1502 fn theoretical_variance(b: f64, c: f64) -> f64 {
1503 pg_variance(b, c)
1504 }
1505
1506 #[test]
1507 fn pg1_cpu_oracle_matches_devroye_mean() {
1508 let n = 25_000;
1512 for &(c, tol) in &[(0.0_f64, 0.05), (1.0, 0.10), (3.0, 0.10)] {
1513 let mut sum = 0.0;
1514 for i in 0..n {
1515 let mut st = XorwowState::new(0xC0FFEE_u64, i as u64);
1516 sum += pg1_draw_cpu_oracle(&mut st, c);
1517 }
1518 let emp = sum / n as f64;
1519 let th = theoretical_mean(1.0, c);
1520 let rel = (emp - th).abs() / th.max(1e-12);
1521 assert!(
1522 rel < tol,
1523 "PG(1,{c}) XORWOW oracle: emp {emp}, theory {th}, rel {rel}"
1524 );
1525 }
1526 }
1527
1528 #[test]
1529 fn pg1_cpu_oracle_variance_matches_theory() {
1530 let n = 100_000;
1531 for &c in &[0.0_f64, 0.5, 2.0, 5.0] {
1532 let mut sum = 0.0;
1533 let mut sum_sq = 0.0;
1534 for i in 0..n {
1535 let mut st = XorwowState::new(0xDEADBEEF_u64, i as u64);
1536 let x = pg1_draw_cpu_oracle(&mut st, c);
1537 sum += x;
1538 sum_sq += x * x;
1539 }
1540 let mean = sum / n as f64;
1541 let var = sum_sq / n as f64 - mean * mean;
1542 let th_var = theoretical_variance(1.0, c);
1543 let rel = (var - th_var).abs() / th_var.max(1e-12);
1544 assert!(
1545 rel < 0.05,
1546 "PG(1,{c}) var: emp {var}, theory {th_var}, rel {rel}"
1547 );
1548 }
1549 }
1550
1551 #[test]
1552 fn xorwow_seeding_is_deterministic() {
1553 let mut a = XorwowState::new(42, 7);
1554 let mut b = XorwowState::new(42, 7);
1555 for _ in 0..1024 {
1556 assert_eq!(a.next_u32(), b.next_u32());
1557 }
1558 let mut c = XorwowState::new(42, 8);
1559 let same = (0..32).all(|_| a.next_u32() == c.next_u32());
1560 assert!(!same, "different rows must produce different streams");
1561 }
1562
1563 #[test]
1564 fn xorwow_unit_in_open_zero_closed_one() {
1565 let mut st = XorwowState::new(123, 0);
1566 for _ in 0..10_000 {
1567 let u = st.next_unit();
1568 assert!(u > 0.0 && u <= 1.0, "u={u} outside (0,1]");
1569 }
1570 }
1571
1572 #[test]
1573 fn saddlepoint_solve_round_trips() {
1574 for &x in &[0.05_f64, 0.3, 0.7, 0.99, 1.01, 1.5, 3.0, 8.0] {
1577 let t = saddlepoint_solve(x);
1578 let kp = if t.abs() < 1e-14 {
1579 1.0
1580 } else if t < 0.0 {
1581 let v = (-2.0 * t).sqrt();
1582 v.tanh() / v
1583 } else {
1584 let v = (2.0 * t).sqrt();
1585 v.tan() / v
1586 };
1587 let rel = (kp - x).abs() / x.max(1e-12);
1588 assert!(
1589 rel < 1e-6,
1590 "saddlepoint_solve(x={x}) → t={t}; K'(t)={kp}, rel={rel}"
1591 );
1592 }
1593 }
1594
1595 #[test]
1596 fn saddlepoint_kpp_is_positive() {
1597 for &t in &[-2.0_f64, -0.5, -1e-5, 0.0, 1e-5, 0.5, 1.0] {
1599 let v = saddlepoint_kpp(t);
1600 assert!(v.is_finite() && v > 0.0, "K''({t}) = {v}");
1601 }
1602 }
1603
1604 #[test]
1605 fn pg_normal_oracle_matches_moments_at_large_b() {
1606 let b = 500u32;
1609 let c = 1.0_f64;
1610 let n = 100_000;
1611 let mut sum = 0.0;
1612 let mut sum_sq = 0.0;
1613 for i in 0..n {
1614 let mut st = XorwowState::new(0xBEEF_u64, i as u64);
1615 let x = pg_normal_cpu_oracle(&mut st, b, c);
1616 sum += x;
1617 sum_sq += x * x;
1618 }
1619 let mean = sum / n as f64;
1620 let var = sum_sq / n as f64 - mean * mean;
1621 let th_mean = theoretical_mean(b as f64, c);
1622 let th_var = theoretical_variance(b as f64, c);
1623 let m_rel = (mean - th_mean).abs() / th_mean;
1624 let v_rel = (var - th_var).abs() / th_var;
1625 assert!(
1626 m_rel < 0.02,
1627 "normal oracle mean: emp {mean}, theory {th_mean}, rel {m_rel}"
1628 );
1629 assert!(
1630 v_rel < 0.05,
1631 "normal oracle var: emp {var}, theory {th_var}, rel {v_rel}"
1632 );
1633 }
1634
1635 #[test]
1636 fn batch_dispatch_selects_every_declared_regime_at_its_boundaries() {
1637 let cases = [
1638 (PG1_MAX_B, -0.75, PolyaGammaCpuRegime::ExactPg1),
1639 (PG1_MAX_B + 1, 0.25, PolyaGammaCpuRegime::ExactConvolution),
1640 (
1641 SADDLE_MIN_B - 1,
1642 1.25,
1643 PolyaGammaCpuRegime::ExactConvolution,
1644 ),
1645 (SADDLE_MIN_B, -1.75, PolyaGammaCpuRegime::Saddlepoint),
1646 (SADDLE_MAX_B, 2.25, PolyaGammaCpuRegime::Saddlepoint),
1647 (NORMAL_MIN_B, -0.5, PolyaGammaCpuRegime::NormalApproximation),
1648 ];
1649 let shapes = Array1::from_vec(cases.iter().map(|case| case.0).collect());
1650 let tilts = Array1::from_vec(cases.iter().map(|case| case.1).collect());
1651 let seed = PgSeed(42);
1652 let input = PolyaGammaBatchInput {
1653 shapes: shapes.view(),
1654 tilts: tilts.view(),
1655 seed,
1656 };
1657 let out = draw_batch_cpu(&input).expect("CPU dispatch");
1658 assert_eq!(out.len(), cases.len());
1659
1660 for (row, &(shape, tilt, expected_regime)) in cases.iter().enumerate() {
1661 assert_eq!(
1662 cpu_regime_for_shape(shape),
1663 expected_regime,
1664 "shape {shape} crossed the wrong declared regime boundary"
1665 );
1666 let mut state = XorwowState::new(seed.0, row as u64);
1667 let expected = match expected_regime {
1668 PolyaGammaCpuRegime::ExactPg1 => pg1_draw_cpu_oracle(&mut state, tilt),
1669 PolyaGammaCpuRegime::ExactConvolution => {
1670 pg_convolution_cpu_oracle(&mut state, shape, tilt)
1671 }
1672 PolyaGammaCpuRegime::Saddlepoint => {
1673 pg_saddlepoint_cpu_oracle(&mut state, shape, tilt)
1674 }
1675 PolyaGammaCpuRegime::NormalApproximation => {
1676 pg_normal_cpu_oracle(&mut state, shape, tilt)
1677 }
1678 };
1679 assert_eq!(
1680 out[row].to_bits(),
1681 expected.to_bits(),
1682 "row {row}, shape {shape}: batch dispatcher did not call {expected_regime:?}"
1683 );
1684 }
1685 }
1686
1687 fn ks_two_sample(a: &mut [f64], b: &mut [f64]) -> f64 {
1696 a.sort_by(|x, y| x.partial_cmp(y).unwrap());
1697 b.sort_by(|x, y| x.partial_cmp(y).unwrap());
1698 let (na, nb) = (a.len() as f64, b.len() as f64);
1699 let (mut i, mut j) = (0usize, 0usize);
1700 let (mut fa, mut fb) = (0.0_f64, 0.0_f64);
1701 let mut d_max = 0.0_f64;
1702 while i < a.len() && j < b.len() {
1703 if a[i] <= b[j] {
1704 i += 1;
1705 fa = i as f64 / na;
1706 } else {
1707 j += 1;
1708 fb = j as f64 / nb;
1709 }
1710 let d = (fa - fb).abs();
1711 if d > d_max {
1712 d_max = d;
1713 }
1714 }
1715 d_max
1716 }
1717
1718 fn ks_critical_001(n_a: usize, n_b: usize) -> f64 {
1723 let na = n_a as f64;
1724 let nb = n_b as f64;
1725 1.6276 * ((na + nb) / (na * nb)).sqrt()
1726 }
1727
1728 #[test]
1729 fn pg1_cpu_oracle_matches_inference_module_distribution() {
1730 use crate::polya_gamma::PolyaGamma;
1736 use rand::{SeedableRng, rngs::StdRng};
1737 let pg = PolyaGamma::new();
1738 for &c in &[0.0_f64, 1.5, 4.0] {
1739 let n_dev = 5_000;
1740 let n_ref = 5_000;
1741 let mut from_oracle: Vec<f64> = (0..n_dev)
1742 .map(|i| {
1743 let mut st = XorwowState::new(0xDEADBEEF_u64 ^ c.to_bits(), i as u64);
1744 pg1_draw_cpu_oracle(&mut st, c)
1745 })
1746 .collect();
1747 let mut from_reference: Vec<f64> = {
1748 let mut rng = StdRng::seed_from_u64(0xABCD_u64 ^ c.to_bits());
1749 (0..n_ref).map(|_| pg.draw(&mut rng, c)).collect()
1750 };
1751 let d = ks_two_sample(&mut from_oracle, &mut from_reference);
1752 let crit = ks_critical_001(n_dev, n_ref);
1753 assert!(
1754 d <= 2.0 * crit,
1755 "PG(1, c={c}) two-sample KS d={d} > 2·crit={}; XORWOW oracle and reference disagree in distribution",
1756 2.0 * crit
1757 );
1758 }
1759 }
1760
1761 #[test]
1766 fn pg1_cpu_oracle_matches_exact_untilted_cdf() {
1767 let sample_count = 20_000usize;
1768 let mut samples: Vec<f64> = (0..sample_count)
1769 .map(|i| {
1770 let mut st = XorwowState::new(0x2320_C0DE, i as u64);
1771 pg1_draw_cpu_oracle(&mut st, 0.0)
1772 })
1773 .collect();
1774 samples.sort_by(f64::total_cmp);
1775
1776 let n = sample_count as f64;
1777 let statistic = samples
1778 .iter()
1779 .enumerate()
1780 .map(|(i, &sample)| {
1781 let cdf = crate::polya_gamma::pg1_untilted_cdf(sample);
1782 let empirical_below = i as f64 / n;
1783 let empirical_through = (i + 1) as f64 / n;
1784 (cdf - empirical_below)
1785 .abs()
1786 .max((empirical_through - cdf).abs())
1787 })
1788 .fold(0.0_f64, f64::max);
1789
1790 let false_rejection_probability = 1e-6_f64;
1793 let critical = (-(false_rejection_probability / 2.0).ln() / (2.0 * n)).sqrt();
1794 assert!(
1795 statistic <= critical,
1796 "CPU exact-PG(1,0) oracle KS statistic {statistic} exceeds DKW critical value {critical}",
1797 );
1798 }
1799
1800 #[test]
1801 fn pg_convolution_identity_at_small_b() {
1802 let n = 4_000;
1807 let b: u32 = 8;
1808 let c: f64 = 1.2;
1809 let mut left: Vec<f64> = (0..n)
1810 .map(|i| {
1811 let mut st = XorwowState::new(0x1111_u64, i as u64);
1814 (0..b).map(|_| pg1_draw_cpu_oracle(&mut st, c)).sum()
1815 })
1816 .collect();
1817 let mut right: Vec<f64> = (0..n)
1818 .map(|i| {
1819 (0..b)
1823 .map(|j| {
1824 let mut st = XorwowState::new(0x2222_u64 ^ (j as u64), i as u64);
1825 pg1_draw_cpu_oracle(&mut st, c)
1826 })
1827 .sum::<f64>()
1828 })
1829 .collect();
1830 let d = ks_two_sample(&mut left, &mut right);
1831 let crit = ks_critical_001(n, n);
1832 assert!(
1833 d <= 2.0 * crit,
1834 "PG({b}, {c}) convolution identity KS d={d} > 2·crit={}",
1835 2.0 * crit
1836 );
1837 }
1838
1839 #[test]
1840 fn pg_normal_kernel_matches_moments_at_b_500() {
1841 let b = 500u32;
1847 let c = 2.0_f64;
1848 let n = 50_000;
1849 let mut sum = 0.0;
1850 let mut sum_sq = 0.0;
1851 for i in 0..n {
1852 let mut st = XorwowState::new(0xCAFE_u64, i as u64);
1853 let x = pg_normal_cpu_oracle(&mut st, b, c);
1854 sum += x;
1855 sum_sq += x * x;
1856 }
1857 let mean = sum / n as f64;
1858 let var = sum_sq / n as f64 - mean * mean;
1859 let th_mean = pg_mean(b as f64, c);
1860 let th_var = pg_variance(b as f64, c);
1861 let m_rel = (mean - th_mean).abs() / th_mean;
1862 let v_rel = (var - th_var).abs() / th_var;
1863 assert!(
1864 m_rel < 0.02,
1865 "normal kernel mean: emp {mean}, theory {th_mean}, rel {m_rel}"
1866 );
1867 assert!(
1868 v_rel < 0.05,
1869 "normal kernel var: emp {var}, theory {th_var}, rel {v_rel}"
1870 );
1871 }
1872
1873 #[test]
1874 fn logistic_gibbs_chain_converges_to_mle_direction() {
1875 use rand::{RngExt, SeedableRng, rngs::StdRng};
1880 let n = 400;
1881 let p = 3;
1882 let beta_star = [1.5_f64, -0.7, 0.3];
1883 let mut design = Array2::<f64>::zeros((n, p));
1884 let mut targets = Array1::<u8>::zeros(n);
1885 let mut rng = StdRng::seed_from_u64(0xFEED);
1886 for i in 0..n {
1887 let x1 = ((i as f64) / (n as f64)) * 2.0 - 1.0;
1888 let x2 = (((i * 13) % n) as f64 / n as f64) * 2.0 - 1.0;
1889 design[[i, 0]] = x1;
1890 design[[i, 1]] = x2;
1891 design[[i, 2]] = 1.0;
1892 let eta = beta_star[0] * x1 + beta_star[1] * x2 + beta_star[2];
1893 let p_y = 1.0 / (1.0 + (-eta).exp());
1894 let u: f64 = rng.random();
1895 targets[i] = if u < p_y { 1 } else { 0 };
1896 }
1897 let q0 = Array2::<f64>::eye(p) * 0.01;
1898 let mut beta = Array1::<f64>::zeros(p);
1899 let mut accum = Array1::<f64>::zeros(p);
1900 let steps = 200;
1901 let burn = 50;
1902 for k in 0..steps {
1903 beta = logistic_gibbs_step(
1904 design.view(),
1905 targets.view(),
1906 q0.view(),
1907 beta.view(),
1908 PgSeed(0xC0DE + k as u64),
1909 0xCAFE + k as u64,
1910 )
1911 .expect("Gibbs step");
1912 if k >= burn {
1913 for j in 0..p {
1914 accum[j] += beta[j];
1915 }
1916 }
1917 }
1918 for j in 0..p {
1919 accum[j] /= (steps - burn) as f64;
1920 }
1921 let dot: f64 = (0..p).map(|j| accum[j] * beta_star[j]).sum();
1922 let na: f64 = accum.iter().map(|v| v * v).sum::<f64>().sqrt();
1923 let nb: f64 = beta_star.iter().map(|v| v * v).sum::<f64>().sqrt();
1924 let cos = dot / (na * nb);
1925 assert!(
1926 cos > 0.85,
1927 "Gibbs chain posterior-mean direction does not align with β*: cos = {cos}, accum = {accum:?}, β* = {beta_star:?}"
1928 );
1929 }
1930
1931 #[test]
1947 #[cfg(target_os = "linux")]
1948 fn polya_gamma_dispatch_worthiness_pg1() {
1949 let n = 200_000usize;
1950 let shapes = Array1::<u32>::from_elem(n, 1);
1951 let mut tilts = Array1::<f64>::zeros(n);
1952 for i in 0..n {
1953 tilts[i] = ((i as f64) / (n as f64)) * 6.0 - 3.0;
1954 }
1955 let seed = PgSeed(0x50_4F_4C_59_47_41_4D_41);
1956
1957 let Some(runtime) = cuda_runtime_for_test("polya_gamma_dispatch_worthiness_pg1") else {
1958 let cpu_draws = assert_draw_batch_declines_to_cpu(&shapes, &tilts, seed);
1964 assert_pg_batch_mean_matches_theory(&cpu_draws, &shapes, &tilts, "pg1 CPU fallback");
1965 return;
1966 };
1967
1968 {
1971 let warm_shapes = Array1::<u32>::from_elem(16, 1);
1972 let warm_tilts = Array1::<f64>::zeros(16);
1973 linux_cuda::draw_batch_gpu(&PolyaGammaBatchInput {
1974 shapes: warm_shapes.view(),
1975 tilts: warm_tilts.view(),
1976 seed,
1977 })
1978 .expect("warm");
1979 }
1980
1981 let t_gpu_start = std::time::Instant::now();
1982 let gpu_draws = linux_cuda::draw_batch_gpu(&PolyaGammaBatchInput {
1983 shapes: shapes.view(),
1984 tilts: tilts.view(),
1985 seed,
1986 })
1987 .expect("GPU draw_batch");
1988 let dt_gpu = t_gpu_start.elapsed().as_secs_f64();
1989
1990 let t_cpu_start = std::time::Instant::now();
1991 let cpu_draws = draw_batch_cpu(&PolyaGammaBatchInput {
1992 shapes: shapes.view(),
1993 tilts: tilts.view(),
1994 seed,
1995 })
1996 .expect("CPU draw_batch");
1997 let dt_cpu = t_cpu_start.elapsed().as_secs_f64();
1998
1999 assert_pg_batch_mean_matches_theory(&gpu_draws, &shapes, &tilts, "pg1 device");
2004 assert_pg_batch_mean_matches_theory(&cpu_draws, &shapes, &tilts, "pg1 CPU baseline");
2005
2006 assert_dispatch_worthy_and_report(
2007 "polya_gamma_hill_climb_pg1",
2008 runtime.policy(),
2009 n,
2010 dt_cpu,
2011 dt_gpu,
2012 );
2013 }
2014
2015 #[test]
2023 #[cfg(target_os = "linux")]
2024 fn polya_gamma_dispatch_worthiness_mixed_nb() {
2025 let n = 200_000usize;
2026 let mut shapes = Array1::<u32>::zeros(n);
2027 let mut tilts = Array1::<f64>::zeros(n);
2028 for i in 0..n {
2029 shapes[i] = if i.is_multiple_of(5) { 1 } else { 250 };
2031 tilts[i] = ((i as f64) / (n as f64)) * 4.0 - 2.0;
2032 }
2033 let seed = PgSeed(0xDEAD_BEEF_CAFE_BABE);
2034
2035 let Some(runtime) = cuda_runtime_for_test("polya_gamma_dispatch_worthiness_mixed_nb") else {
2036 let cpu_draws = assert_draw_batch_declines_to_cpu(&shapes, &tilts, seed);
2039 assert_pg_batch_mean_matches_theory(
2040 &cpu_draws,
2041 &shapes,
2042 &tilts,
2043 "mixed-NB CPU fallback",
2044 );
2045 return;
2046 };
2047
2048 let warm_shapes = Array1::<u32>::from_elem(16, 250);
2050 let warm_tilts = Array1::<f64>::zeros(16);
2051 linux_cuda::draw_batch_gpu(&PolyaGammaBatchInput {
2052 shapes: warm_shapes.view(),
2053 tilts: warm_tilts.view(),
2054 seed,
2055 })
2056 .expect("warm");
2057
2058 let t_gpu = std::time::Instant::now();
2059 let gpu_draws = linux_cuda::draw_batch_gpu(&PolyaGammaBatchInput {
2060 shapes: shapes.view(),
2061 tilts: tilts.view(),
2062 seed,
2063 })
2064 .expect("GPU mixed");
2065 let dt_gpu = t_gpu.elapsed().as_secs_f64();
2066
2067 let t_cpu = std::time::Instant::now();
2068 let cpu_draws = draw_batch_cpu(&PolyaGammaBatchInput {
2069 shapes: shapes.view(),
2070 tilts: tilts.view(),
2071 seed,
2072 })
2073 .expect("CPU mixed");
2074 let dt_cpu = t_cpu.elapsed().as_secs_f64();
2075
2076 assert_pg_batch_mean_matches_theory(&gpu_draws, &shapes, &tilts, "mixed-NB device");
2080 assert_pg_batch_mean_matches_theory(&cpu_draws, &shapes, &tilts, "mixed-NB CPU baseline");
2081
2082 assert_dispatch_worthy_and_report(
2083 "polya_gamma_hill_climb_mixed",
2084 runtime.policy(),
2085 n,
2086 dt_cpu,
2087 dt_gpu,
2088 );
2089 }
2090
2091 #[test]
2095 #[cfg(target_os = "linux")]
2096 fn pg1_gpu_matches_cpu_oracle_when_runtime_available() {
2097 let on_cuda =
2098 cuda_runtime_for_test("pg1_gpu_matches_cpu_oracle_when_runtime_available").is_some();
2099 let sample_count = 4_096usize;
2100 let shapes = Array1::<u32>::from_elem(sample_count, 1);
2101 for &tilt in &[0.0_f64, 1.5, 4.0] {
2102 let tilts = Array1::<f64>::from_elem(sample_count, tilt);
2103 if !on_cuda {
2104 let cpu_draws = assert_draw_batch_declines_to_cpu(
2110 &shapes,
2111 &tilts,
2112 PgSeed(0x9E37_79B9_7F4A_7C15 ^ tilt.to_bits()),
2113 );
2114 assert_pg_batch_mean_matches_theory(
2115 &cpu_draws,
2116 &shapes,
2117 &tilts,
2118 "pg1 CPU fallback parity",
2119 );
2120 continue;
2121 }
2122 let mut gpu = linux_cuda::draw_batch_gpu(&PolyaGammaBatchInput {
2123 shapes: shapes.view(),
2124 tilts: tilts.view(),
2125 seed: PgSeed(0x9E37_79B9_7F4A_7C15 ^ tilt.to_bits()),
2126 })
2127 .expect("GPU draw_batch")
2128 .to_vec();
2129 let mut cpu = draw_batch_cpu(&PolyaGammaBatchInput {
2130 shapes: shapes.view(),
2131 tilts: tilts.view(),
2132 seed: PgSeed(0xD1B5_4A32_D192_ED03 ^ tilt.to_bits()),
2133 })
2134 .expect("CPU draw_batch")
2135 .to_vec();
2136 let statistic = ks_two_sample(&mut gpu, &mut cpu);
2137 let critical = ks_critical_001(sample_count, sample_count);
2138 assert!(
2139 statistic <= 2.0 * critical,
2140 "PG(1, {tilt}) CUDA/upstream KS statistic {statistic} exceeds {}",
2141 2.0 * critical,
2142 );
2143 }
2144 }
2145
2146 #[test]
2154 #[cfg(target_os = "linux")]
2155 fn cuda_source_uses_rendered_constants_only() {
2156 let rendered = render_cuda_devroye_constants();
2157 let assembled = linux_cuda::ptx_source();
2158 assert!(
2159 assembled.contains(rendered.trim_end()),
2160 "assembled CUDA source does not embed the rendered constant block"
2161 );
2162 let define_count = assembled.matches("#define PG_").count();
2165 let rendered_count = rendered.matches("#define PG_").count();
2166 assert_eq!(
2167 define_count, rendered_count,
2168 "CUDA source has {define_count} `#define PG_` lines but the rendered block has {rendered_count}; a stale hand-typed constant is present"
2169 );
2170 }
2171}