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> {
498 input.validate()?;
499
500 #[cfg(target_os = "linux")]
501 {
502 if gam_gpu::device_runtime::GpuRuntime::resolve(gam_gpu::global_policy())
503 .map_err(String::from)?
504 .is_some()
505 {
506 return linux_cuda::draw_batch_gpu(&input).map_err(String::from);
507 }
508 }
509
510 draw_batch_cpu(&input)
511}
512
513pub fn logistic_gibbs_step(
534 design: ArrayView2<'_, f64>,
535 targets: ArrayView1<'_, u8>,
536 prior_precision: ArrayView2<'_, f64>,
537 beta: ArrayView1<'_, f64>,
538 seed: PgSeed,
539 norm_seed: u64,
540) -> Result<Array1<f64>, String> {
541 let (n, p) = design.dim();
542 if targets.len() != n {
543 return Err(format!(
544 "logistic_gibbs_step: y.len()={} != n={n}",
545 targets.len()
546 ));
547 }
548 if prior_precision.dim() != (p, p) {
549 return Err(format!(
550 "logistic_gibbs_step: Q_0 shape {:?} != ({p}, {p})",
551 prior_precision.dim()
552 ));
553 }
554 if beta.len() != p {
555 return Err(format!(
556 "logistic_gibbs_step: beta.len()={} != p={p}",
557 beta.len()
558 ));
559 }
560
561 let mut psi = Array1::<f64>::zeros(n);
563 for i in 0..n {
564 let mut acc = 0.0;
565 for j in 0..p {
566 acc += design[[i, j]] * beta[j];
567 }
568 psi[i] = acc;
569 }
570
571 let shapes = Array1::<u32>::from_elem(n, 1);
573 let omega = draw_batch(PolyaGammaBatchInput {
574 shapes: shapes.view(),
575 tilts: psi.view(),
576 seed,
577 })?;
578
579 let mut m = Array1::<f64>::zeros(p);
582 for i in 0..n {
583 let r = targets[i] as f64 - 0.5;
584 for j in 0..p {
585 m[j] += design[[i, j]] * r;
586 }
587 }
588
589 let mut q = prior_precision.to_owned();
591 for i in 0..n {
592 let w = omega[i];
593 for a in 0..p {
594 let xa = design[[i, a]];
595 for b in 0..p {
596 q[[a, b]] += w * xa * design[[i, b]];
597 }
598 }
599 }
600
601 let l = cholesky_lower_inplace(q.clone())
603 .map_err(|e| format!("logistic_gibbs_step Cholesky: {e}"))?;
604 let mean = cholesky_solve_vector(&l, &m);
606
607 let mut norm_state = XorwowState::new(norm_seed, 0);
609 let mut eta = Array1::<f64>::zeros(p);
610 for j in 0..p {
611 eta[j] = norm_state.next_norm();
612 }
613 let perturb = back_substitution_lower_transpose(&l, &eta);
614 let mut beta_new = Array1::<f64>::zeros(p);
615 for j in 0..p {
616 beta_new[j] = mean[j] + perturb[j];
617 }
618 Ok(beta_new)
619}
620
621fn cholesky_lower_inplace(mut a: Array2<f64>) -> Result<Array2<f64>, String> {
622 let n = a.nrows();
623 for i in 0..n {
624 for j in 0..=i {
625 let mut sum = a[[i, j]];
626 for k in 0..j {
627 sum -= a[[i, k]] * a[[j, k]];
628 }
629 if i == j {
630 if sum <= 0.0 {
631 return Err(format!("non-SPD diagonal {sum} at row {i}"));
632 }
633 a[[i, j]] = sum.sqrt();
634 } else {
635 a[[i, j]] = sum / a[[j, j]];
636 }
637 }
638 for j in (i + 1)..n {
639 a[[i, j]] = 0.0;
640 }
641 }
642 Ok(a)
643}
644
645#[cfg(target_os = "linux")]
650fn render_cuda_devroye_constants() -> String {
651 let two_over_pi = std::f64::consts::FRAC_2_PI;
652 let pi_squared = PI * PI;
653 let sqrt_two_over_pi = two_over_pi.sqrt();
654 let sqrt_pi_over_two = FRAC_PI_2.sqrt();
655 format!(
656 "#define PG_FRAC_2_PI ({two_over_pi:.20e})\n\
657 #define PG_PI ({PI:.20e})\n\
658 #define PG_PI_SQ ({pi_squared:.20e})\n\
659 #define PG_SQRT_2_OVER_PI ({sqrt_two_over_pi:.20e})\n\
660 #define PG_SQRT_PI_OVER_2 ({sqrt_pi_over_two:.20e})\n",
661 )
662}
663
664#[cfg(target_os = "linux")]
669mod linux_cuda {
670 use super::{
671 PG1_MAX_B, PgSeed, PolyaGammaBatchInput, SADDLE_MAX_B, SADDLE_MIN_B, XorwowState,
672 pg_convolution_cpu_oracle, pg_normal_cpu_oracle, render_cuda_devroye_constants,
673 };
674 use cudarc::driver::{CudaContext, CudaModule, CudaStream, LaunchConfig, PushKernelArg};
675 use gam_gpu::gpu_error::{GpuError, GpuResultExt};
676 use gam_gpu::solver::context_and_stream;
677 use ndarray::Array1;
678 use std::sync::Arc;
679
680 const PTX_SOURCE_PRELUDE: &str = r#"
701extern "C" __device__ unsigned long long splitmix64_mix(unsigned long long z) {
702 z += 0x9E3779B97F4A7C15ULL;
703 unsigned long long x = z;
704 x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9ULL;
705 x = (x ^ (x >> 27)) * 0x94D049BB133111EBULL;
706 return x ^ (x >> 31);
707}
708
709// Per-row XORWOW state. Layout mirrors curand_kernel.h::curandStateXORWOW_t
710// for the five 32-bit state lanes plus the addition counter. We omit the
711// boxmuller_extra/boxmuller_flag cache since our normal draws use the
712// polar method (which discards the second variate).
713struct XorwowState {
714 unsigned int s0, s1, s2, s3, s4, d;
715};
716
717extern "C" __device__ void xorwow_seed(struct XorwowState* st, unsigned long long seed, unsigned long long row) {
718 const unsigned long long ROW_ZETA = 0xA1B2C3D4E5F67890ULL;
719 const unsigned long long WORD_GAMMA = 0x0F1E2D3C4B5A6978ULL;
720 unsigned int words[6];
721 for (int w = 0; w < 6; ++w) {
722 unsigned long long composite = seed ^ (row * ROW_ZETA) ^ ((unsigned long long)w * WORD_GAMMA);
723 unsigned long long h = splitmix64_mix(composite);
724 words[w] = (unsigned int)(h >> 32);
725 }
726 if ((words[0] | words[1] | words[2] | words[3] | words[4]) == 0u) {
727 words[0] = 1u;
728 }
729 st->s0 = words[0]; st->s1 = words[1]; st->s2 = words[2];
730 st->s3 = words[3]; st->s4 = words[4]; st->d = words[5];
731}
732
733extern "C" __device__ unsigned int xorwow_next(struct XorwowState* st) {
734 unsigned int t = st->s4;
735 unsigned int s = st->s0;
736 st->s4 = st->s3;
737 st->s3 = st->s2;
738 st->s2 = st->s1;
739 st->s1 = s;
740 t ^= (t >> 2);
741 t ^= (t << 1);
742 t ^= s ^ (s << 4);
743 st->s0 = t;
744 st->d += 362437u;
745 return t + st->d;
746}
747
748extern "C" __device__ double xorwow_unit(struct XorwowState* st) {
749 unsigned int raw = xorwow_next(st);
750 return ((double)raw + 1.0) * (1.0 / 4294967296.0);
751}
752
753extern "C" __device__ double xorwow_exp(struct XorwowState* st) {
754 return -log(xorwow_unit(st));
755}
756
757extern "C" __device__ double xorwow_norm(struct XorwowState* st) {
758 // Marsaglia polar — discard the partner variate, matches host oracle
759 // byte-for-byte (host also discards).
760 for (;;) {
761 double u = 2.0 * xorwow_unit(st) - 1.0;
762 double v = 2.0 * xorwow_unit(st) - 1.0;
763 double s = u * u + v * v;
764 if (s > 0.0 && s < 1.0) {
765 double factor = sqrt(-2.0 * log(s) / s);
766 return u * factor;
767 }
768 }
769}
770"#;
771
772 const PTX_SOURCE_BODY: &str = r#"
778extern "C" __device__ double std_normal_cdf(double x) {
779 // 0.5 · erfc(-x / sqrt(2)).
780 return 0.5 * erfc(-x * 0.7071067811865475);
781}
782
783extern "C" __device__ double pg_series(int n, double x) {
784 if (x <= 0.0) return 0.0;
785 double k = (double)n + 0.5;
786 double k_sq = k * k;
787 if (x <= PG_FRAC_2_PI) {
788 double inv_x = 1.0 / x;
789 return (2.0 * k * PG_SQRT_2_OVER_PI) * inv_x * sqrt(inv_x) * exp(-2.0 * k_sq * inv_x);
790 } else {
791 // Right branch — corrected coefficient PI · k (not PI / 2).
792 return PG_PI * k * exp(-0.5 * k_sq * PG_PI_SQ * x);
793 }
794}
795
796extern "C" __device__ double pg_log_std_normal_cdf(double x) {
797 // ln Φ(x): direct log of erfc in the bulk; leading Mills-ratio
798 // asymptotic once erfc underflows (x <~ -38).
799 double erfc_val = erfc(-x * 0.7071067811865475);
800 if (erfc_val > 0.0) {
801 return log(erfc_val) - 0.6931471805599453;
802 }
803 return -0.5 * x * x - log(-x) - 0.9189385332046727;
804}
805
806extern "C" __device__ double pg_exp_tail_mass(double tilt) {
807 double base = 0.125 * PG_PI_SQ + 0.5 * tilt * tilt;
808 double upper = PG_SQRT_PI_OVER_2 * (PG_FRAC_2_PI * tilt - 1.0);
809 double lower = -(PG_SQRT_PI_OVER_2 * (PG_FRAC_2_PI * tilt + 1.0));
810 double log_growth = base * PG_FRAC_2_PI;
811 double exp_terms;
812 if (log_growth + tilt <= 600.0) {
813 // Bulk regime for the CUDA implementation.
814 double base_factor = base * exp(log_growth);
815 double p_upper = base_factor * exp(-tilt) * std_normal_cdf(upper);
816 double p_lower = base_factor * exp( tilt) * std_normal_cdf(lower);
817 exp_terms = (4.0 / PG_PI) * (p_upper + p_lower);
818 } else {
819 // Extreme tilt: the folded product forms inf * 0 = NaN; assemble
820 // each term in log space (same expression, regrouped), mirroring
821 // the host TAIL_MASS_DIRECT_MAX_LOG branch.
822 double log_base = log(base);
823 double lp_upper = log_base + log_growth - tilt + pg_log_std_normal_cdf(upper);
824 double lp_lower = log_base + log_growth + tilt + pg_log_std_normal_cdf(lower);
825 exp_terms = (4.0 / PG_PI) * (exp(lp_upper) + exp(lp_lower));
826 }
827 return 1.0 / (1.0 + exp_terms);
828}
829
830extern "C" __device__ double sample_small_z(struct XorwowState* st, double z, double trunc) {
831 double accept = 0.0;
832 double sample = 0.0;
833 while (accept < xorwow_unit(st)) {
834 double exp_sample;
835 for (;;) {
836 double e1 = xorwow_exp(st);
837 double e2 = xorwow_exp(st);
838 if (e1 * e1 <= 2.0 * e2 / trunc) { exp_sample = e1; break; }
839 }
840 sample = 1.0 + exp_sample * trunc;
841 sample = trunc / (sample * sample);
842 accept = exp(-0.5 * z * z * sample);
843 }
844 return sample;
845}
846
847extern "C" __device__ double sample_large_z(struct XorwowState* st, double mean, double trunc) {
848 double sample = 1.0e300;
849 while (sample > trunc) {
850 double n = xorwow_norm(st);
851 double n_sq = n * n;
852 double half_mean = 0.5 * mean;
853 double mn_sq = mean * n_sq;
854 double disc = sqrt(4.0 * mn_sq + mn_sq * mn_sq);
855 sample = mean + half_mean * mn_sq - half_mean * disc;
856 if (xorwow_unit(st) > mean / (mean + sample)) {
857 sample = mean * mean / sample;
858 }
859 }
860 return sample;
861}
862
863extern "C" __device__ double sample_trunc_inv_gauss(struct XorwowState* st, double z, double trunc) {
864 double az = fabs(z);
865 if (PG_FRAC_2_PI > az) {
866 return sample_small_z(st, az, trunc);
867 } else {
868 return sample_large_z(st, 1.0 / az, trunc);
869 }
870}
871
872extern "C" __device__ double pg1_draw(struct XorwowState* st, double tilt) {
873 double half_tilt = fabs(tilt) * 0.5;
874 double scale = 0.125 * PG_PI_SQ + 0.5 * half_tilt * half_tilt;
875 double exp_mass = pg_exp_tail_mass(half_tilt);
876
877 for (;;) {
878 double u = xorwow_unit(st);
879 double proposal;
880 if (u < exp_mass) {
881 proposal = PG_FRAC_2_PI + xorwow_exp(st) / scale;
882 } else {
883 proposal = sample_trunc_inv_gauss(st, half_tilt, PG_FRAC_2_PI);
884 }
885 double sum = pg_series(0, proposal);
886 double threshold = xorwow_unit(st) * sum;
887 int idx = 0;
888 // The alternating-series tail. Bounded iteration cap (64) is
889 // overwhelmingly safe: PSW 2013 show termination in <10 iters
890 // with probability >1 - 1e-30 for any tilt; the cap exists only
891 // to guarantee forward progress under hardware fault.
892 for (int outer = 0; outer < 64; ++outer) {
893 idx += 1;
894 double term = pg_series(idx, proposal);
895 if (idx & 1) {
896 sum -= term;
897 if (threshold <= sum) {
898 return 0.25 * proposal;
899 }
900 } else {
901 sum += term;
902 if (threshold >= sum) {
903 break;
904 }
905 }
906 }
907 }
908}
909
910// ── Saddlepoint helpers (math §9) ────────────────────────────────────────
911
912extern "C" __device__ double saddlepoint_t(double x) {
913 if (fabs(x - 1.0) < 1.0e-9) return 0.0;
914 if (x < 1.0) {
915 double v = sqrt(3.0 * (1.0 - x)); if (v < 1.0e-6) v = 1.0e-6;
916 for (int it = 0; it < 6; ++it) {
917 double tanh_v = tanh(v);
918 double f = tanh_v / v - x;
919 double sech_sq = 1.0 - tanh_v * tanh_v;
920 double df = (sech_sq - tanh_v / v) / v;
921 v -= f / df;
922 if (fabs(v) < 1.0e-12) break;
923 }
924 return -0.5 * v * v;
925 } else {
926 double v = sqrt(3.0 * (x - 1.0));
927 if (v > 0.49 * PG_PI) v = 0.49 * PG_PI;
928 if (v < 1.0e-6) v = 1.0e-6;
929 for (int it = 0; it < 6; ++it) {
930 double tan_v = tan(v);
931 double f = tan_v / v - x;
932 double sec_sq = 1.0 + tan_v * tan_v;
933 double df = (sec_sq - tan_v / v) / v;
934 v -= f / df;
935 if (v < 1.0e-6) v = 1.0e-6;
936 if (v > 0.499999 * PG_PI) v = 0.499999 * PG_PI;
937 }
938 return 0.5 * v * v;
939 }
940}
941
942// ── Kernels ──────────────────────────────────────────────────────────────
943
944extern "C" __global__ void pg1_kernel(
945 unsigned long long seed,
946 unsigned int n,
947 const unsigned int* __restrict__ rows, // index map into shapes/tilts/out, length n
948 const double* __restrict__ tilts,
949 double* __restrict__ out)
950{
951 unsigned int slot = blockIdx.x * blockDim.x + threadIdx.x;
952 if (slot >= n) return;
953 unsigned int row = rows[slot];
954 struct XorwowState st;
955 xorwow_seed(&st, seed, (unsigned long long)row);
956 double c = tilts[row];
957 out[row] = pg1_draw(&st, c);
958}
959
960extern "C" __global__ void sp_kernel(
961 unsigned long long seed,
962 unsigned int n,
963 const unsigned int* __restrict__ rows,
964 const unsigned int* __restrict__ shapes,
965 const double* __restrict__ tilts,
966 double* __restrict__ out)
967{
968 unsigned int slot = blockIdx.x * blockDim.x + threadIdx.x;
969 if (slot >= n) return;
970 unsigned int row = rows[slot];
971 struct XorwowState st;
972 xorwow_seed(&st, seed, (unsigned long long)row);
973 unsigned int b = shapes[row];
974 double c = tilts[row];
975 // Convolution-equivalent device fallback: sum b PG(1, c) draws. This
976 // is correct in distribution; the *true* saddlepoint envelope ships
977 // with phase 3 hill-climb. Until then, the kernel is callable and
978 // produces draws that pass the §12 KS test — the only thing the
979 // saddlepoint is supposed to buy is throughput at large b.
980 double acc = 0.0;
981 for (unsigned int j = 0; j < b; ++j) {
982 acc += pg1_draw(&st, c);
983 }
984 // Touch saddlepoint_t so the helper isn’t DCE’d before phase 3 wiring;
985 // the value is unused (multiplied by zero) so this is free.
986 double sp_warm = saddlepoint_t(0.5);
987 out[row] = acc + 0.0 * sp_warm;
988}
989
990extern "C" __global__ void normal_kernel(
991 unsigned long long seed,
992 unsigned int n,
993 const unsigned int* __restrict__ rows,
994 const unsigned int* __restrict__ shapes,
995 const double* __restrict__ tilts,
996 double* __restrict__ out)
997{
998 unsigned int slot = blockIdx.x * blockDim.x + threadIdx.x;
999 if (slot >= n) return;
1000 unsigned int row = rows[slot];
1001 struct XorwowState st;
1002 xorwow_seed(&st, seed, (unsigned long long)row);
1003 double b = (double)shapes[row];
1004 double c = fabs(tilts[row]);
1005 double mean;
1006 double var;
1007 if (c < 1.0e-8) {
1008 mean = 0.25 * b;
1009 var = b / 24.0;
1010 } else {
1011 mean = b * tanh(0.5 * c) / (2.0 * c);
1012 // (sinh c - c)/(1 + cosh c) == tanh(c/2) - c/(1 + cosh c): stable when
1013 // cosh overflows (tanh saturates, second term -> 0), unlike the raw
1014 // form's inf/inf = NaN. Matches the Rust pg_variance helper.
1015 double ratio = tanh(0.5 * c) - c / (1.0 + cosh(c));
1016 var = b * ratio / (2.0 * c * c * c);
1017 }
1018 double sd = sqrt(var);
1019 double draw = mean + sd * xorwow_norm(&st);
1020 if (draw <= 0.0) draw = -draw + 1.0e-300;
1021 out[row] = draw;
1022}
1023"#;
1024
1025 const THREADS_PER_BLOCK: u32 = 128;
1026
1027 pub(super) fn ptx_source() -> String {
1030 let mut src = String::with_capacity(PTX_SOURCE_PRELUDE.len() + PTX_SOURCE_BODY.len() + 256);
1031 src.push_str(PTX_SOURCE_PRELUDE);
1032 src.push_str(
1033 "\n// ── Devroye PG(1, c) constants (derived by the Rust host) ────────────\n",
1034 );
1035 src.push_str(&render_cuda_devroye_constants());
1036 src.push_str(PTX_SOURCE_BODY);
1037 src
1038 }
1039
1040 fn module(ctx: &Arc<CudaContext>) -> Result<&'static Arc<CudaModule>, GpuError> {
1041 static CACHE: gam_gpu::device_cache::PtxModuleCache =
1042 gam_gpu::device_cache::PtxModuleCache::new();
1043 CACHE.get_or_compile(ctx, "polya_gamma", &ptx_source())
1044 }
1045
1046 pub(super) fn draw_batch_gpu(
1047 input: &PolyaGammaBatchInput<'_>,
1048 ) -> Result<Array1<f64>, GpuError> {
1049 let n = input.rows();
1050 if n == 0 {
1051 return Ok(Array1::<f64>::zeros(0));
1052 }
1053 let (ctx, stream) =
1054 context_and_stream().map_err(|reason| GpuError::DriverCallFailed { reason })?;
1055 let compiled = module(&ctx)?;
1056 let module_handle: &Arc<CudaModule> = compiled;
1057
1058 let mut pg1_rows: Vec<u32> = Vec::new();
1064 let mut sp_rows: Vec<u32> = Vec::new();
1065 let mut normal_rows: Vec<u32> = Vec::new();
1066 let mut host_rows: Vec<u32> = Vec::new();
1067 for (i, &b) in input.shapes.iter().enumerate() {
1068 let idx = i as u32;
1069 if b <= PG1_MAX_B {
1070 pg1_rows.push(idx);
1071 } else if b < SADDLE_MIN_B {
1072 host_rows.push(idx);
1073 } else if b <= SADDLE_MAX_B {
1074 sp_rows.push(idx);
1075 } else {
1076 normal_rows.push(idx);
1077 }
1078 }
1079
1080 let tilts_vec: Vec<f64> = match input.tilts.as_slice() {
1083 Some(s) => s.to_vec(),
1084 None => input.tilts.iter().copied().collect(),
1085 };
1086 let shapes_vec: Vec<u32> = match input.shapes.as_slice() {
1087 Some(s) => s.to_vec(),
1088 None => input.shapes.iter().copied().collect(),
1089 };
1090 let tilts_dev = stream
1091 .clone_htod(&tilts_vec)
1092 .gpu_ctx("polya_gamma upload tilts")?;
1093 let shapes_dev = stream
1094 .clone_htod(&shapes_vec)
1095 .gpu_ctx("polya_gamma upload shapes")?;
1096 let mut out_dev = stream
1097 .alloc_zeros::<f64>(n)
1098 .gpu_ctx("polya_gamma alloc out")?;
1099
1100 if !pg1_rows.is_empty() {
1102 let rows_dev = stream
1103 .clone_htod(&pg1_rows)
1104 .gpu_ctx("polya_gamma upload pg1 rows")?;
1105 launch_pg1(
1106 &stream,
1107 module_handle,
1108 input.seed,
1109 &rows_dev,
1110 &tilts_dev,
1111 &mut out_dev,
1112 )?;
1113 }
1114 if !sp_rows.is_empty() {
1115 let rows_dev = stream
1116 .clone_htod(&sp_rows)
1117 .gpu_ctx("polya_gamma upload sp rows")?;
1118 launch_sp(
1119 &stream,
1120 module_handle,
1121 input.seed,
1122 &rows_dev,
1123 &shapes_dev,
1124 &tilts_dev,
1125 &mut out_dev,
1126 )?;
1127 }
1128 if !normal_rows.is_empty() {
1129 let rows_dev = stream
1130 .clone_htod(&normal_rows)
1131 .gpu_ctx("polya_gamma upload normal rows")?;
1132 launch_normal(
1133 &stream,
1134 module_handle,
1135 input.seed,
1136 &rows_dev,
1137 &shapes_dev,
1138 &tilts_dev,
1139 &mut out_dev,
1140 )?;
1141 }
1142
1143 let mut out_host = stream
1145 .clone_dtoh(&out_dev)
1146 .gpu_ctx("polya_gamma download out")?;
1147 for &row in &host_rows {
1148 let i = row as usize;
1149 let mut st = XorwowState::new(input.seed.0, row as u64);
1150 let b = input.shapes[i];
1151 let c = input.tilts[i];
1152 out_host[i] = if b <= SADDLE_MAX_B {
1153 pg_convolution_cpu_oracle(&mut st, b, c)
1154 } else {
1155 pg_normal_cpu_oracle(&mut st, b, c)
1158 };
1159 }
1160 Ok(Array1::from_vec(out_host))
1161 }
1162
1163 fn launch_pg1(
1164 stream: &Arc<CudaStream>,
1165 module: &Arc<CudaModule>,
1166 seed: PgSeed,
1167 rows: &cudarc::driver::CudaSlice<u32>,
1168 tilts: &cudarc::driver::CudaSlice<f64>,
1169 out: &mut cudarc::driver::CudaSlice<f64>,
1170 ) -> Result<(), GpuError> {
1171 let func = module
1172 .load_function("pg1_kernel")
1173 .gpu_ctx("polya_gamma load pg1_kernel")?;
1174 let n = rows.len() as u32;
1175 let grid = (n + THREADS_PER_BLOCK - 1) / THREADS_PER_BLOCK;
1176 let cfg = LaunchConfig {
1177 grid_dim: (grid, 1, 1),
1178 block_dim: (THREADS_PER_BLOCK, 1, 1),
1179 shared_mem_bytes: 0,
1180 };
1181 let seed_arg: u64 = seed.0;
1182 unsafe {
1185 stream
1186 .launch_builder(&func)
1187 .arg(&seed_arg)
1188 .arg(&n)
1189 .arg(rows)
1190 .arg(tilts)
1191 .arg(out)
1192 .launch(cfg)
1193 }
1194 .map(|_| ())
1195 .gpu_ctx("polya_gamma launch pg1_kernel")
1196 }
1197
1198 fn launch_sp(
1199 stream: &Arc<CudaStream>,
1200 module: &Arc<CudaModule>,
1201 seed: PgSeed,
1202 rows: &cudarc::driver::CudaSlice<u32>,
1203 shapes: &cudarc::driver::CudaSlice<u32>,
1204 tilts: &cudarc::driver::CudaSlice<f64>,
1205 out: &mut cudarc::driver::CudaSlice<f64>,
1206 ) -> Result<(), GpuError> {
1207 let func = module
1208 .load_function("sp_kernel")
1209 .gpu_ctx("polya_gamma load sp_kernel")?;
1210 let n = rows.len() as u32;
1211 let grid = (n + THREADS_PER_BLOCK - 1) / THREADS_PER_BLOCK;
1212 let cfg = LaunchConfig {
1213 grid_dim: (grid, 1, 1),
1214 block_dim: (THREADS_PER_BLOCK, 1, 1),
1215 shared_mem_bytes: 0,
1216 };
1217 let seed_arg: u64 = seed.0;
1218 unsafe {
1221 stream
1222 .launch_builder(&func)
1223 .arg(&seed_arg)
1224 .arg(&n)
1225 .arg(rows)
1226 .arg(shapes)
1227 .arg(tilts)
1228 .arg(out)
1229 .launch(cfg)
1230 }
1231 .map(|_| ())
1232 .gpu_ctx("polya_gamma launch sp_kernel")
1233 }
1234
1235 fn launch_normal(
1236 stream: &Arc<CudaStream>,
1237 module: &Arc<CudaModule>,
1238 seed: PgSeed,
1239 rows: &cudarc::driver::CudaSlice<u32>,
1240 shapes: &cudarc::driver::CudaSlice<u32>,
1241 tilts: &cudarc::driver::CudaSlice<f64>,
1242 out: &mut cudarc::driver::CudaSlice<f64>,
1243 ) -> Result<(), GpuError> {
1244 let func = module
1245 .load_function("normal_kernel")
1246 .gpu_ctx("polya_gamma load normal_kernel")?;
1247 let n = rows.len() as u32;
1248 let grid = (n + THREADS_PER_BLOCK - 1) / THREADS_PER_BLOCK;
1249 let cfg = LaunchConfig {
1250 grid_dim: (grid, 1, 1),
1251 block_dim: (THREADS_PER_BLOCK, 1, 1),
1252 shared_mem_bytes: 0,
1253 };
1254 let seed_arg: u64 = seed.0;
1255 unsafe {
1257 stream
1258 .launch_builder(&func)
1259 .arg(&seed_arg)
1260 .arg(&n)
1261 .arg(rows)
1262 .arg(shapes)
1263 .arg(tilts)
1264 .arg(out)
1265 .launch(cfg)
1266 }
1267 .map(|_| ())
1268 .gpu_ctx("polya_gamma launch normal_kernel")
1269 }
1270}
1271
1272#[cfg(test)]
1277mod tests {
1278 use super::*;
1279
1280 #[cfg(target_os = "linux")]
1281 fn cuda_runtime_for_test(test_name: &str) -> Option<&'static gam_gpu::device_runtime::GpuRuntime> {
1282 match gam_gpu::device_runtime::GpuRuntime::resolve(gam_gpu::GpuPolicy::Auto) {
1283 Ok(Some(runtime)) => Some(runtime),
1284 Ok(None) => {
1285 eprintln!("[{test_name}] no CUDA device on host — skipping");
1286 None
1287 }
1288 Err(error) => panic!("[{test_name}] CUDA probe failed: {error}"),
1289 }
1290 }
1291
1292 fn theoretical_mean(b: f64, c: f64) -> f64 {
1293 pg_mean(b, c)
1294 }
1295
1296 fn theoretical_variance(b: f64, c: f64) -> f64 {
1297 pg_variance(b, c)
1298 }
1299
1300 #[test]
1301 fn pg1_cpu_oracle_matches_devroye_mean() {
1302 let n = 25_000;
1306 for &(c, tol) in &[(0.0_f64, 0.05), (1.0, 0.10), (3.0, 0.10)] {
1307 let mut sum = 0.0;
1308 for i in 0..n {
1309 let mut st = XorwowState::new(0xC0FFEE_u64, i as u64);
1310 sum += pg1_draw_cpu_oracle(&mut st, c);
1311 }
1312 let emp = sum / n as f64;
1313 let th = theoretical_mean(1.0, c);
1314 let rel = (emp - th).abs() / th.max(1e-12);
1315 assert!(
1316 rel < tol,
1317 "PG(1,{c}) XORWOW oracle: emp {emp}, theory {th}, rel {rel}"
1318 );
1319 }
1320 }
1321
1322 #[test]
1323 fn pg1_cpu_oracle_variance_matches_theory() {
1324 let n = 100_000;
1325 for &c in &[0.0_f64, 0.5, 2.0, 5.0] {
1326 let mut sum = 0.0;
1327 let mut sum_sq = 0.0;
1328 for i in 0..n {
1329 let mut st = XorwowState::new(0xDEADBEEF_u64, i as u64);
1330 let x = pg1_draw_cpu_oracle(&mut st, c);
1331 sum += x;
1332 sum_sq += x * x;
1333 }
1334 let mean = sum / n as f64;
1335 let var = sum_sq / n as f64 - mean * mean;
1336 let th_var = theoretical_variance(1.0, c);
1337 let rel = (var - th_var).abs() / th_var.max(1e-12);
1338 assert!(
1339 rel < 0.05,
1340 "PG(1,{c}) var: emp {var}, theory {th_var}, rel {rel}"
1341 );
1342 }
1343 }
1344
1345 #[test]
1346 fn xorwow_seeding_is_deterministic() {
1347 let mut a = XorwowState::new(42, 7);
1348 let mut b = XorwowState::new(42, 7);
1349 for _ in 0..1024 {
1350 assert_eq!(a.next_u32(), b.next_u32());
1351 }
1352 let mut c = XorwowState::new(42, 8);
1353 let same = (0..32).all(|_| a.next_u32() == c.next_u32());
1354 assert!(!same, "different rows must produce different streams");
1355 }
1356
1357 #[test]
1358 fn xorwow_unit_in_open_zero_closed_one() {
1359 let mut st = XorwowState::new(123, 0);
1360 for _ in 0..10_000 {
1361 let u = st.next_unit();
1362 assert!(u > 0.0 && u <= 1.0, "u={u} outside (0,1]");
1363 }
1364 }
1365
1366 #[test]
1367 fn saddlepoint_solve_round_trips() {
1368 for &x in &[0.05_f64, 0.3, 0.7, 0.99, 1.01, 1.5, 3.0, 8.0] {
1371 let t = saddlepoint_solve(x);
1372 let kp = if t.abs() < 1e-14 {
1373 1.0
1374 } else if t < 0.0 {
1375 let v = (-2.0 * t).sqrt();
1376 v.tanh() / v
1377 } else {
1378 let v = (2.0 * t).sqrt();
1379 v.tan() / v
1380 };
1381 let rel = (kp - x).abs() / x.max(1e-12);
1382 assert!(
1383 rel < 1e-6,
1384 "saddlepoint_solve(x={x}) → t={t}; K'(t)={kp}, rel={rel}"
1385 );
1386 }
1387 }
1388
1389 #[test]
1390 fn saddlepoint_kpp_is_positive() {
1391 for &t in &[-2.0_f64, -0.5, -1e-5, 0.0, 1e-5, 0.5, 1.0] {
1393 let v = saddlepoint_kpp(t);
1394 assert!(v.is_finite() && v > 0.0, "K''({t}) = {v}");
1395 }
1396 }
1397
1398 #[test]
1399 fn pg_normal_oracle_matches_moments_at_large_b() {
1400 let b = 500u32;
1403 let c = 1.0_f64;
1404 let n = 100_000;
1405 let mut sum = 0.0;
1406 let mut sum_sq = 0.0;
1407 for i in 0..n {
1408 let mut st = XorwowState::new(0xBEEF_u64, i as u64);
1409 let x = pg_normal_cpu_oracle(&mut st, b, c);
1410 sum += x;
1411 sum_sq += x * x;
1412 }
1413 let mean = sum / n as f64;
1414 let var = sum_sq / n as f64 - mean * mean;
1415 let th_mean = theoretical_mean(b as f64, c);
1416 let th_var = theoretical_variance(b as f64, c);
1417 let m_rel = (mean - th_mean).abs() / th_mean;
1418 let v_rel = (var - th_var).abs() / th_var;
1419 assert!(
1420 m_rel < 0.02,
1421 "normal oracle mean: emp {mean}, theory {th_mean}, rel {m_rel}"
1422 );
1423 assert!(
1424 v_rel < 0.05,
1425 "normal oracle var: emp {var}, theory {th_var}, rel {v_rel}"
1426 );
1427 }
1428
1429 #[test]
1430 fn batch_dispatch_selects_every_declared_regime_at_its_boundaries() {
1431 let cases = [
1432 (PG1_MAX_B, -0.75, PolyaGammaCpuRegime::ExactPg1),
1433 (PG1_MAX_B + 1, 0.25, PolyaGammaCpuRegime::ExactConvolution),
1434 (
1435 SADDLE_MIN_B - 1,
1436 1.25,
1437 PolyaGammaCpuRegime::ExactConvolution,
1438 ),
1439 (SADDLE_MIN_B, -1.75, PolyaGammaCpuRegime::Saddlepoint),
1440 (SADDLE_MAX_B, 2.25, PolyaGammaCpuRegime::Saddlepoint),
1441 (NORMAL_MIN_B, -0.5, PolyaGammaCpuRegime::NormalApproximation),
1442 ];
1443 let shapes = Array1::from_vec(cases.iter().map(|case| case.0).collect());
1444 let tilts = Array1::from_vec(cases.iter().map(|case| case.1).collect());
1445 let seed = PgSeed(42);
1446 let input = PolyaGammaBatchInput {
1447 shapes: shapes.view(),
1448 tilts: tilts.view(),
1449 seed,
1450 };
1451 let out = draw_batch_cpu(&input).expect("CPU dispatch");
1452 assert_eq!(out.len(), cases.len());
1453
1454 for (row, &(shape, tilt, expected_regime)) in cases.iter().enumerate() {
1455 assert_eq!(
1456 cpu_regime_for_shape(shape),
1457 expected_regime,
1458 "shape {shape} crossed the wrong declared regime boundary"
1459 );
1460 let mut state = XorwowState::new(seed.0, row as u64);
1461 let expected = match expected_regime {
1462 PolyaGammaCpuRegime::ExactPg1 => pg1_draw_cpu_oracle(&mut state, tilt),
1463 PolyaGammaCpuRegime::ExactConvolution => {
1464 pg_convolution_cpu_oracle(&mut state, shape, tilt)
1465 }
1466 PolyaGammaCpuRegime::Saddlepoint => {
1467 pg_saddlepoint_cpu_oracle(&mut state, shape, tilt)
1468 }
1469 PolyaGammaCpuRegime::NormalApproximation => {
1470 pg_normal_cpu_oracle(&mut state, shape, tilt)
1471 }
1472 };
1473 assert_eq!(
1474 out[row].to_bits(),
1475 expected.to_bits(),
1476 "row {row}, shape {shape}: batch dispatcher did not call {expected_regime:?}"
1477 );
1478 }
1479 }
1480
1481 fn ks_two_sample(a: &mut [f64], b: &mut [f64]) -> f64 {
1490 a.sort_by(|x, y| x.partial_cmp(y).unwrap());
1491 b.sort_by(|x, y| x.partial_cmp(y).unwrap());
1492 let (na, nb) = (a.len() as f64, b.len() as f64);
1493 let (mut i, mut j) = (0usize, 0usize);
1494 let (mut fa, mut fb) = (0.0_f64, 0.0_f64);
1495 let mut d_max = 0.0_f64;
1496 while i < a.len() && j < b.len() {
1497 if a[i] <= b[j] {
1498 i += 1;
1499 fa = i as f64 / na;
1500 } else {
1501 j += 1;
1502 fb = j as f64 / nb;
1503 }
1504 let d = (fa - fb).abs();
1505 if d > d_max {
1506 d_max = d;
1507 }
1508 }
1509 d_max
1510 }
1511
1512 fn ks_critical_001(n_a: usize, n_b: usize) -> f64 {
1517 let na = n_a as f64;
1518 let nb = n_b as f64;
1519 1.6276 * ((na + nb) / (na * nb)).sqrt()
1520 }
1521
1522 #[test]
1523 fn pg1_cpu_oracle_matches_inference_module_distribution() {
1524 use crate::polya_gamma::PolyaGamma;
1530 use rand::{SeedableRng, rngs::StdRng};
1531 let pg = PolyaGamma::new();
1532 for &c in &[0.0_f64, 1.5, 4.0] {
1533 let n_dev = 5_000;
1534 let n_ref = 5_000;
1535 let mut from_oracle: Vec<f64> = (0..n_dev)
1536 .map(|i| {
1537 let mut st = XorwowState::new(0xDEADBEEF_u64 ^ c.to_bits(), i as u64);
1538 pg1_draw_cpu_oracle(&mut st, c)
1539 })
1540 .collect();
1541 let mut from_reference: Vec<f64> = {
1542 let mut rng = StdRng::seed_from_u64(0xABCD_u64 ^ c.to_bits());
1543 (0..n_ref).map(|_| pg.draw(&mut rng, c)).collect()
1544 };
1545 let d = ks_two_sample(&mut from_oracle, &mut from_reference);
1546 let crit = ks_critical_001(n_dev, n_ref);
1547 assert!(
1548 d <= 2.0 * crit,
1549 "PG(1, c={c}) two-sample KS d={d} > 2·crit={}; XORWOW oracle and reference disagree in distribution",
1550 2.0 * crit
1551 );
1552 }
1553 }
1554
1555 #[test]
1560 fn pg1_cpu_oracle_matches_exact_untilted_cdf() {
1561 let sample_count = 20_000usize;
1562 let mut samples: Vec<f64> = (0..sample_count)
1563 .map(|i| {
1564 let mut st = XorwowState::new(0x2320_C0DE, i as u64);
1565 pg1_draw_cpu_oracle(&mut st, 0.0)
1566 })
1567 .collect();
1568 samples.sort_by(f64::total_cmp);
1569
1570 let n = sample_count as f64;
1571 let statistic = samples
1572 .iter()
1573 .enumerate()
1574 .map(|(i, &sample)| {
1575 let cdf = crate::polya_gamma::pg1_untilted_cdf(sample);
1576 let empirical_below = i as f64 / n;
1577 let empirical_through = (i + 1) as f64 / n;
1578 (cdf - empirical_below)
1579 .abs()
1580 .max((empirical_through - cdf).abs())
1581 })
1582 .fold(0.0_f64, f64::max);
1583
1584 let false_rejection_probability = 1e-6_f64;
1587 let critical = (-(false_rejection_probability / 2.0).ln() / (2.0 * n)).sqrt();
1588 assert!(
1589 statistic <= critical,
1590 "CPU exact-PG(1,0) oracle KS statistic {statistic} exceeds DKW critical value {critical}",
1591 );
1592 }
1593
1594 #[test]
1595 fn pg_convolution_identity_at_small_b() {
1596 let n = 4_000;
1601 let b: u32 = 8;
1602 let c: f64 = 1.2;
1603 let mut left: Vec<f64> = (0..n)
1604 .map(|i| {
1605 let mut st = XorwowState::new(0x1111_u64, i as u64);
1608 (0..b).map(|_| pg1_draw_cpu_oracle(&mut st, c)).sum()
1609 })
1610 .collect();
1611 let mut right: Vec<f64> = (0..n)
1612 .map(|i| {
1613 (0..b)
1617 .map(|j| {
1618 let mut st = XorwowState::new(0x2222_u64 ^ (j as u64), i as u64);
1619 pg1_draw_cpu_oracle(&mut st, c)
1620 })
1621 .sum::<f64>()
1622 })
1623 .collect();
1624 let d = ks_two_sample(&mut left, &mut right);
1625 let crit = ks_critical_001(n, n);
1626 assert!(
1627 d <= 2.0 * crit,
1628 "PG({b}, {c}) convolution identity KS d={d} > 2·crit={}",
1629 2.0 * crit
1630 );
1631 }
1632
1633 #[test]
1634 fn pg_normal_kernel_matches_moments_at_b_500() {
1635 let b = 500u32;
1641 let c = 2.0_f64;
1642 let n = 50_000;
1643 let mut sum = 0.0;
1644 let mut sum_sq = 0.0;
1645 for i in 0..n {
1646 let mut st = XorwowState::new(0xCAFE_u64, i as u64);
1647 let x = pg_normal_cpu_oracle(&mut st, b, c);
1648 sum += x;
1649 sum_sq += x * x;
1650 }
1651 let mean = sum / n as f64;
1652 let var = sum_sq / n as f64 - mean * mean;
1653 let th_mean = pg_mean(b as f64, c);
1654 let th_var = pg_variance(b as f64, c);
1655 let m_rel = (mean - th_mean).abs() / th_mean;
1656 let v_rel = (var - th_var).abs() / th_var;
1657 assert!(
1658 m_rel < 0.02,
1659 "normal kernel mean: emp {mean}, theory {th_mean}, rel {m_rel}"
1660 );
1661 assert!(
1662 v_rel < 0.05,
1663 "normal kernel var: emp {var}, theory {th_var}, rel {v_rel}"
1664 );
1665 }
1666
1667 #[test]
1668 fn logistic_gibbs_chain_converges_to_mle_direction() {
1669 use rand::{RngExt, SeedableRng, rngs::StdRng};
1674 let n = 400;
1675 let p = 3;
1676 let beta_star = [1.5_f64, -0.7, 0.3];
1677 let mut design = Array2::<f64>::zeros((n, p));
1678 let mut targets = Array1::<u8>::zeros(n);
1679 let mut rng = StdRng::seed_from_u64(0xFEED);
1680 for i in 0..n {
1681 let x1 = ((i as f64) / (n as f64)) * 2.0 - 1.0;
1682 let x2 = (((i * 13) % n) as f64 / n as f64) * 2.0 - 1.0;
1683 design[[i, 0]] = x1;
1684 design[[i, 1]] = x2;
1685 design[[i, 2]] = 1.0;
1686 let eta = beta_star[0] * x1 + beta_star[1] * x2 + beta_star[2];
1687 let p_y = 1.0 / (1.0 + (-eta).exp());
1688 let u: f64 = rng.random();
1689 targets[i] = if u < p_y { 1 } else { 0 };
1690 }
1691 let q0 = Array2::<f64>::eye(p) * 0.01;
1692 let mut beta = Array1::<f64>::zeros(p);
1693 let mut accum = Array1::<f64>::zeros(p);
1694 let steps = 200;
1695 let burn = 50;
1696 for k in 0..steps {
1697 beta = logistic_gibbs_step(
1698 design.view(),
1699 targets.view(),
1700 q0.view(),
1701 beta.view(),
1702 PgSeed(0xC0DE + k as u64),
1703 0xCAFE + k as u64,
1704 )
1705 .expect("Gibbs step");
1706 if k >= burn {
1707 for j in 0..p {
1708 accum[j] += beta[j];
1709 }
1710 }
1711 }
1712 for j in 0..p {
1713 accum[j] /= (steps - burn) as f64;
1714 }
1715 let dot: f64 = (0..p).map(|j| accum[j] * beta_star[j]).sum();
1716 let na: f64 = accum.iter().map(|v| v * v).sum::<f64>().sqrt();
1717 let nb: f64 = beta_star.iter().map(|v| v * v).sum::<f64>().sqrt();
1718 let cos = dot / (na * nb);
1719 assert!(
1720 cos > 0.85,
1721 "Gibbs chain posterior-mean direction does not align with β*: cos = {cos}, accum = {accum:?}, β* = {beta_star:?}"
1722 );
1723 }
1724
1725 #[test]
1739 #[cfg(target_os = "linux")]
1740 fn polya_gamma_dispatch_worthiness_pg1_3x() {
1741 if cuda_runtime_for_test("polya_gamma_dispatch_worthiness_pg1_3x").is_none() {
1742 return;
1743 }
1744 let n = 200_000usize;
1745 let shapes = Array1::<u32>::from_elem(n, 1);
1746 let mut tilts = Array1::<f64>::zeros(n);
1747 for i in 0..n {
1748 tilts[i] = ((i as f64) / (n as f64)) * 6.0 - 3.0;
1749 }
1750 let seed = PgSeed(0x50_4F_4C_59_47_41_4D_41);
1751
1752 {
1755 let warm_shapes = Array1::<u32>::from_elem(16, 1);
1756 let warm_tilts = Array1::<f64>::zeros(16);
1757 linux_cuda::draw_batch_gpu(&PolyaGammaBatchInput {
1758 shapes: warm_shapes.view(),
1759 tilts: warm_tilts.view(),
1760 seed,
1761 })
1762 .expect("warm");
1763 }
1764
1765 let t_gpu_start = std::time::Instant::now();
1766 linux_cuda::draw_batch_gpu(&PolyaGammaBatchInput {
1767 shapes: shapes.view(),
1768 tilts: tilts.view(),
1769 seed,
1770 })
1771 .expect("GPU draw_batch");
1772 let dt_gpu = t_gpu_start.elapsed().as_secs_f64();
1773
1774 let t_cpu_start = std::time::Instant::now();
1775 draw_batch_cpu(&PolyaGammaBatchInput {
1776 shapes: shapes.view(),
1777 tilts: tilts.view(),
1778 seed,
1779 })
1780 .expect("CPU draw_batch");
1781 let dt_cpu = t_cpu_start.elapsed().as_secs_f64();
1782
1783 let speedup = dt_cpu / dt_gpu;
1784 println!(
1785 "polya_gamma_hill_climb_pg1: n={n} cpu={dt_cpu:.3}s gpu={dt_gpu:.3}s speedup={speedup:.1}×"
1786 );
1787 assert!(
1788 speedup >= 3.0,
1798 "PG(1) GPU speedup {speedup:.1}× < 3× dispatch-worthiness gate (cpu={dt_cpu:.3}s, gpu={dt_gpu:.3}s)"
1799 );
1800 }
1801
1802 #[test]
1808 #[cfg(target_os = "linux")]
1809 fn polya_gamma_dispatch_worthiness_mixed_nb_3x() {
1810 if cuda_runtime_for_test("polya_gamma_dispatch_worthiness_mixed_nb_3x").is_none() {
1811 return;
1812 }
1813 let n = 200_000usize;
1814 let mut shapes = Array1::<u32>::zeros(n);
1815 let mut tilts = Array1::<f64>::zeros(n);
1816 for i in 0..n {
1817 shapes[i] = if i.is_multiple_of(5) { 1 } else { 250 };
1819 tilts[i] = ((i as f64) / (n as f64)) * 4.0 - 2.0;
1820 }
1821 let seed = PgSeed(0xDEAD_BEEF_CAFE_BABE);
1822
1823 let warm_shapes = Array1::<u32>::from_elem(16, 250);
1825 let warm_tilts = Array1::<f64>::zeros(16);
1826 linux_cuda::draw_batch_gpu(&PolyaGammaBatchInput {
1827 shapes: warm_shapes.view(),
1828 tilts: warm_tilts.view(),
1829 seed,
1830 })
1831 .expect("warm");
1832
1833 let t_gpu = std::time::Instant::now();
1834 linux_cuda::draw_batch_gpu(&PolyaGammaBatchInput {
1835 shapes: shapes.view(),
1836 tilts: tilts.view(),
1837 seed,
1838 })
1839 .expect("GPU mixed");
1840 let dt_gpu = t_gpu.elapsed().as_secs_f64();
1841
1842 let t_cpu = std::time::Instant::now();
1843 draw_batch_cpu(&PolyaGammaBatchInput {
1844 shapes: shapes.view(),
1845 tilts: tilts.view(),
1846 seed,
1847 })
1848 .expect("CPU mixed");
1849 let dt_cpu = t_cpu.elapsed().as_secs_f64();
1850
1851 let speedup = dt_cpu / dt_gpu;
1852 println!(
1853 "polya_gamma_hill_climb_mixed: n={n} cpu={dt_cpu:.3}s gpu={dt_gpu:.3}s speedup={speedup:.1}×"
1854 );
1855 assert!(
1856 speedup >= 3.0,
1859 "Mixed NB GPU speedup {speedup:.1}× < 3× dispatch-worthiness gate (cpu={dt_cpu:.3}s, gpu={dt_gpu:.3}s)"
1860 );
1861 }
1862
1863 #[test]
1867 #[cfg(target_os = "linux")]
1868 fn pg1_gpu_matches_cpu_oracle_when_runtime_available() {
1869 if cuda_runtime_for_test("pg1_gpu_matches_cpu_oracle_when_runtime_available").is_none() {
1870 return;
1871 }
1872 let sample_count = 4_096usize;
1873 let shapes = Array1::<u32>::from_elem(sample_count, 1);
1874 for &tilt in &[0.0_f64, 1.5, 4.0] {
1875 let tilts = Array1::<f64>::from_elem(sample_count, tilt);
1876 let mut gpu = linux_cuda::draw_batch_gpu(&PolyaGammaBatchInput {
1877 shapes: shapes.view(),
1878 tilts: tilts.view(),
1879 seed: PgSeed(0x9E37_79B9_7F4A_7C15 ^ tilt.to_bits()),
1880 })
1881 .expect("GPU draw_batch")
1882 .to_vec();
1883 let mut cpu = draw_batch_cpu(&PolyaGammaBatchInput {
1884 shapes: shapes.view(),
1885 tilts: tilts.view(),
1886 seed: PgSeed(0xD1B5_4A32_D192_ED03 ^ tilt.to_bits()),
1887 })
1888 .expect("CPU draw_batch")
1889 .to_vec();
1890 let statistic = ks_two_sample(&mut gpu, &mut cpu);
1891 let critical = ks_critical_001(sample_count, sample_count);
1892 assert!(
1893 statistic <= 2.0 * critical,
1894 "PG(1, {tilt}) CUDA/upstream KS statistic {statistic} exceeds {}",
1895 2.0 * critical,
1896 );
1897 }
1898 }
1899
1900 #[test]
1908 #[cfg(target_os = "linux")]
1909 fn cuda_source_uses_rendered_constants_only() {
1910 let rendered = render_cuda_devroye_constants();
1911 let assembled = linux_cuda::ptx_source();
1912 assert!(
1913 assembled.contains(rendered.trim_end()),
1914 "assembled CUDA source does not embed the rendered constant block"
1915 );
1916 let define_count = assembled.matches("#define PG_").count();
1919 let rendered_count = rendered.matches("#define PG_").count();
1920 assert_eq!(
1921 define_count, rendered_count,
1922 "CUDA source has {define_count} `#define PG_` lines but the rendered block has {rendered_count}; a stale hand-typed constant is present"
1923 );
1924 }
1925}