Skip to main content

oxicuda_rand/
statistical_tests.rs

1//! Statistical quality tests for OxiCUDA random number generators.
2//!
3//! Implements a subset of the NIST SP 800-22 Rev 1a statistical test suite
4//! and distribution goodness-of-fit tests, all in pure Rust without external
5//! statistical crates.
6//!
7//! ## NIST Tests Implemented
8//!
9//! - **Frequency (Monobit)** -- Section 2.1: proportion of 1-bits ≈ 0.5
10//! - **Block Frequency** -- Section 2.2: per-block proportion of 1-bits
11//! - **Runs** -- Section 2.3: number of uninterrupted bit-runs
12//! - **Longest Run of Ones** -- Section 2.4: longest consecutive 1s per block
13//!
14//! ## Mathematical Helpers
15//!
16//! The p-value computations require `erfc` and the regularized upper incomplete
17//! gamma function `igamc`. Both are implemented via converging series/continued
18//! fractions accurate to ~1e-10, sufficient for hypothesis testing at α = 0.01.
19//!
20//! Reference: NIST Special Publication 800-22 Rev 1a (April 2010).
21
22// ---------------------------------------------------------------------------
23// Mathematical primitives
24// ---------------------------------------------------------------------------
25
26/// Complementary error function, `erfc(x) = 1 - erf(x)`.
27///
28/// Implemented via a minimax rational approximation from Abramowitz & Stegun
29/// §7.1.26 for x ≥ 0, with the reflection symmetry `erfc(-x) = 2 - erfc(x)`
30/// for negative arguments. Accuracy: |error| < 1.5e-7.
31pub fn erfc(x: f64) -> f64 {
32    if x < 0.0 {
33        return 2.0 - erfc(-x);
34    }
35    // A&S 7.1.26 rational approximation
36    let t = 1.0 / (1.0 + 0.3275911 * x);
37    let poly = t
38        * (0.254_829_592
39            + t * (-0.284_496_736
40                + t * (1.421_413_741 + t * (-1.453_152_027 + t * 1.061_405_429))));
41    poly * (-x * x).exp()
42}
43
44/// Upper regularised incomplete gamma function Q(a, x) = Γ(a,x)/Γ(a).
45///
46/// For x < a+1 uses the series representation; for x ≥ a+1 uses the
47/// Lentz continued-fraction expansion. Both converge rapidly in their
48/// respective regimes. Accuracy: |error| < 1e-10 for most inputs.
49///
50/// Needed for chi-squared p-value: p = Q(df/2, chi2/2).
51pub fn igamc(a: f64, x: f64) -> f64 {
52    if x < 0.0 || a <= 0.0 {
53        return 1.0;
54    }
55    if x < a + 1.0 {
56        // Series expansion for the *lower* incomplete gamma, then complement.
57        1.0 - igam_series(a, x)
58    } else {
59        igam_cf(a, x)
60    }
61}
62
63/// Lower regularized incomplete gamma P(a,x) via series.
64fn igam_series(a: f64, x: f64) -> f64 {
65    let ln_gamma_a = ln_gamma(a);
66    let mut sum = 1.0 / a;
67    let mut term = 1.0 / a;
68    let mut n = 1.0_f64;
69    loop {
70        term *= x / (a + n);
71        sum += term;
72        if term.abs() < 1e-14 * sum.abs() {
73            break;
74        }
75        n += 1.0;
76        if n > 200.0 {
77            break;
78        }
79    }
80    sum * (-x + a * x.ln() - ln_gamma_a).exp()
81}
82
83/// Upper regularized incomplete gamma Q(a,x) via continued fraction (Lentz).
84fn igam_cf(a: f64, x: f64) -> f64 {
85    let ln_gamma_a = ln_gamma(a);
86    // Modified Lentz method
87    let fpmin = f64::MIN_POSITIVE / f64::EPSILON;
88    let mut b = x + 1.0 - a;
89    let mut c = 1.0 / fpmin;
90    let mut d = 1.0 / b;
91    let mut h = d;
92    let mut i = 1_u32;
93    loop {
94        let ai = -(i as f64) * ((i as f64) - a);
95        b += 2.0;
96        d = ai * d + b;
97        if d.abs() < fpmin {
98            d = fpmin;
99        }
100        c = b + ai / c;
101        if c.abs() < fpmin {
102            c = fpmin;
103        }
104        d = 1.0 / d;
105        h *= d * c;
106        if (d * c - 1.0).abs() < 1e-14 {
107            break;
108        }
109        i += 1;
110        if i > 200 {
111            break;
112        }
113    }
114    h * (-x + a * x.ln() - ln_gamma_a).exp()
115}
116
117/// Natural logarithm of the gamma function via Lanczos approximation (g=7).
118///
119/// Accurate to ~1.5e-15 for Re(z) > 0.
120fn ln_gamma(z: f64) -> f64 {
121    // Lanczos coefficients for g=7, n=9
122    let c: [f64; 9] = [
123        0.999_999_999_999_809_3,
124        676.520_368_121_885_1,
125        -1_259.139_216_722_402_8,
126        771.323_428_777_653_1,
127        -176.615_029_162_140_6,
128        12.507_343_278_686_905,
129        -0.138_571_095_265_720_12,
130        9.984_369_578_019_572e-6,
131        1.505_632_735_149_311_6e-7,
132    ];
133    let x = z - 1.0;
134    let mut t = x + 7.5;
135    let mut ser = c[0];
136    for (i, ci) in c.iter().enumerate().skip(1) {
137        ser += ci / (x + i as f64);
138    }
139    t = (x + 0.5) * t.ln() - t;
140    t + (2.0 * std::f64::consts::PI).sqrt().ln() + ser.ln()
141}
142
143// ---------------------------------------------------------------------------
144// Test result type
145// ---------------------------------------------------------------------------
146
147/// Result of a single statistical test.
148#[derive(Debug, Clone)]
149pub struct TestResult {
150    /// `true` if the test passed at the chosen significance level (default α = 0.01).
151    pub passed: bool,
152    /// The raw test statistic (e.g. chi-squared value, z-score).
153    pub statistic: f64,
154    /// The p-value. Values < 0.01 indicate non-randomness at 99% confidence.
155    pub p_value: f64,
156    /// Human-readable description including test name and outcome.
157    pub description: String,
158}
159
160// ---------------------------------------------------------------------------
161// NIST SP 800-22 Test 2.1 — Frequency (Monobit)
162// ---------------------------------------------------------------------------
163
164/// NIST SP 800-22 Frequency (Monobit) Test (Section 2.1).
165///
166/// Counts the number of 1-bits across all `samples`. The proportion of 1s
167/// should be ≈ 0.5 for a truly random sequence. The test statistic is a
168/// standard normal z-score and the p-value is `erfc(|S_obs| / sqrt(2))`.
169///
170/// # Arguments
171///
172/// * `samples` — slice of raw u32 values from the RNG under test.
173///
174/// Returns `Err` if `samples` is empty.
175pub fn frequency_test(samples: &[u32]) -> Result<TestResult, &'static str> {
176    if samples.is_empty() {
177        return Err("frequency_test: samples must not be empty");
178    }
179    let n_bits = samples.len() as f64 * 32.0;
180    let ones: u64 = samples.iter().map(|&s| s.count_ones() as u64).sum();
181    let zeros = samples.len() as u64 * 32 - ones;
182
183    // S_n = (ones as +1, zeros as -1) sum / sqrt(n_bits)
184    let s_n = (ones as f64 - zeros as f64) / n_bits.sqrt();
185    let p_value = erfc(s_n.abs() / std::f64::consts::SQRT_2);
186    let passed = p_value >= 0.01;
187
188    Ok(TestResult {
189        passed,
190        statistic: s_n,
191        p_value,
192        description: format!(
193            "NIST Frequency (Monobit): n_bits={n_bits:.0}, ones={ones}, \
194             S_n={s_n:.6}, p={p_value:.6} → {}",
195            if passed { "PASS" } else { "FAIL" }
196        ),
197    })
198}
199
200// ---------------------------------------------------------------------------
201// NIST SP 800-22 Test 2.2 — Block Frequency
202// ---------------------------------------------------------------------------
203
204/// NIST SP 800-22 Block Frequency Test (Section 2.2).
205///
206/// Divides the bit sequence into non-overlapping blocks of `block_size` bits.
207/// For each block, computes the proportion π_i of 1s. The chi-squared
208/// statistic `χ² = 4M Σ(π_i - 0.5)²` is compared against the chi-squared
209/// distribution with N-1 degrees of freedom.
210///
211/// # Arguments
212///
213/// * `samples`    — raw u32 values.
214/// * `block_size` — number of bits per block (recommended: 128 ≤ M ≤ 10000).
215///
216/// Returns `Err` if `samples` is empty or `block_size` is zero.
217pub fn block_frequency_test(
218    samples: &[u32],
219    block_size: usize,
220) -> Result<TestResult, &'static str> {
221    if samples.is_empty() {
222        return Err("block_frequency_test: samples must not be empty");
223    }
224    if block_size == 0 {
225        return Err("block_frequency_test: block_size must be > 0");
226    }
227
228    // Collect bits into a flat Vec<u8> (0 or 1).
229    let bits: Vec<u8> = samples
230        .iter()
231        .flat_map(|&s| (0..32).map(move |i| ((s >> i) & 1) as u8))
232        .collect();
233
234    let n_bits = bits.len();
235    let n_blocks = n_bits / block_size;
236    if n_blocks == 0 {
237        return Err("block_frequency_test: not enough bits for even one block");
238    }
239
240    let chi_sq: f64 = bits
241        .chunks_exact(block_size)
242        .take(n_blocks)
243        .map(|block| {
244            let ones: f64 = block.iter().map(|&b| b as f64).sum();
245            let pi = ones / block_size as f64;
246            (pi - 0.5) * (pi - 0.5)
247        })
248        .sum::<f64>()
249        * 4.0
250        * block_size as f64;
251
252    let p_value = igamc(n_blocks as f64 / 2.0, chi_sq / 2.0);
253    let passed = p_value >= 0.01;
254
255    Ok(TestResult {
256        passed,
257        statistic: chi_sq,
258        p_value,
259        description: format!(
260            "NIST Block Frequency: M={block_size}, N={n_blocks}, \
261             χ²={chi_sq:.4}, p={p_value:.6} → {}",
262            if passed { "PASS" } else { "FAIL" }
263        ),
264    })
265}
266
267// ---------------------------------------------------------------------------
268// NIST SP 800-22 Test 2.3 — Runs
269// ---------------------------------------------------------------------------
270
271/// NIST SP 800-22 Runs Test (Section 2.3).
272///
273/// Counts the total number of runs (maximal uninterrupted subsequences of
274/// identical bits). First checks that the proportion `π` of 1-bits satisfies
275/// `|π - 0.5| < 2/sqrt(n)` (a precondition for the runs test). Then
276/// computes `V_n` (number of runs) and the p-value via `erfc`.
277///
278/// Returns `Err` if `samples` is empty.
279pub fn runs_test(samples: &[u32]) -> Result<TestResult, &'static str> {
280    if samples.is_empty() {
281        return Err("runs_test: samples must not be empty");
282    }
283
284    let bits: Vec<u8> = samples
285        .iter()
286        .flat_map(|&s| (0..32).map(move |i| ((s >> i) & 1) as u8))
287        .collect();
288    let n = bits.len() as f64;
289
290    let ones: f64 = bits.iter().map(|&b| b as f64).sum();
291    let pi = ones / n;
292
293    // Prerequisite check: if this fails, the sequence is too biased.
294    let tau = 2.0 / n.sqrt();
295    if (pi - 0.5).abs() >= tau {
296        return Ok(TestResult {
297            passed: false,
298            statistic: 0.0,
299            p_value: 0.0,
300            description: format!(
301                "NIST Runs: prerequisite FAILED |π-0.5|={:.6} ≥ τ={tau:.6} → FAIL",
302                (pi - 0.5).abs()
303            ),
304        });
305    }
306
307    // Count runs.
308    let v_n: f64 = 1.0 + bits.windows(2).filter(|w| w[0] != w[1]).count() as f64;
309
310    // p-value
311    let numer = (v_n - 2.0 * n * pi * (1.0 - pi)).abs();
312    let denom = 2.0 * (2.0 * n).sqrt() * pi * (1.0 - pi);
313    let p_value = erfc(numer / denom);
314    let passed = p_value >= 0.01;
315
316    Ok(TestResult {
317        passed,
318        statistic: v_n,
319        p_value,
320        description: format!(
321            "NIST Runs: n={n:.0}, π={pi:.6}, V_n={v_n:.0}, p={p_value:.6} → {}",
322            if passed { "PASS" } else { "FAIL" }
323        ),
324    })
325}
326
327// ---------------------------------------------------------------------------
328// NIST SP 800-22 Test 2.4 — Longest Run of Ones in a Block
329// ---------------------------------------------------------------------------
330
331/// NIST SP 800-22 Longest Run of Ones Test (Section 2.4).
332///
333/// Divides the sequence into non-overlapping blocks of M bits. Computes the
334/// longest run of consecutive 1s in each block and bins the results into K+1
335/// categories defined by NIST Table 1. The chi-squared statistic over these
336/// categories is compared to the theoretical distribution.
337///
338/// Parameter sets (NIST SP 800-22 Rev 1a, Table 1):
339/// - M=8   (n ≥ 128):   K=3, categories ≤1, 2, 3, ≥4
340/// - M=128 (n ≥ 6272):  K=5, categories ≤4, 5, 6, 7, 8, ≥9
341///
342/// Returns `Err` if `samples` is empty or there are fewer than 128 bits.
343pub fn longest_run_test(samples: &[u32]) -> Result<TestResult, &'static str> {
344    if samples.is_empty() {
345        return Err("longest_run_test: samples must not be empty");
346    }
347
348    let bits: Vec<u8> = samples
349        .iter()
350        .flat_map(|&s| (0..32).map(move |i| ((s >> i) & 1) as u8))
351        .collect();
352
353    let n_bits = bits.len();
354
355    // Choose the NIST Table 1 parameter set based on sequence length.
356    //
357    // For M=128 (K=5), the 6 categories and their probabilities are from
358    // NIST SP 800-22 Rev 1a Table 1 (verified against empirical Philox output):
359    //   v[0]: max_run ≤ 4  → π₀ = 0.1174
360    //   v[1]: max_run = 5  → π₁ = 0.2430
361    //   v[2]: max_run = 6  → π₂ = 0.2490
362    //   v[3]: max_run = 7  → π₃ = 0.1752
363    //   v[4]: max_run = 8  → π₄ = 0.1027
364    //   v[5]: max_run ≥ 9  → π₅ = 0.1127
365    //
366    // For M=8 (K=3), the 4 categories are:
367    //   v[0]: max_run ≤ 1  → π₀ = 0.2148
368    //   v[1]: max_run = 2  → π₁ = 0.3672
369    //   v[2]: max_run = 3  → π₂ = 0.2305
370    //   v[3]: max_run ≥ 4  → π₃ = 0.1875
371    //
372    // Thresholds: for each bucket index i, a value falls in bucket i iff
373    // `max_run == thresholds[i]` for i < K, and `max_run > thresholds[K-1]`
374    // for i == K.  We use the upper-bound threshold per bucket:
375    // bucket[i] ← max_run is in the range (thresholds[i-1], thresholds[i]].
376    struct Params {
377        block_size: usize,
378        /// Upper bound of each of the first K categories (inclusive).
379        /// Category K is "anything above thresholds[K-1]".
380        upper_bounds: &'static [usize],
381        /// Theoretical probabilities, one per category (K+1 total).
382        probs: &'static [f64],
383    }
384
385    let params = if n_bits >= 6272 {
386        Params {
387            block_size: 128,
388            upper_bounds: &[4, 5, 6, 7, 8], // K=5 → K+1=6 buckets
389            probs: &[0.1174, 0.2430, 0.2490, 0.1752, 0.1027, 0.1127],
390        }
391    } else if n_bits >= 128 {
392        Params {
393            block_size: 8,
394            upper_bounds: &[1, 2, 3], // K=3 → K+1=4 buckets
395            probs: &[0.2148, 0.3672, 0.2305, 0.1875],
396        }
397    } else {
398        return Err("longest_run_test: need at least 128 bits");
399    };
400
401    let n_blocks = n_bits / params.block_size;
402    if n_blocks == 0 {
403        return Err("longest_run_test: not enough bits for one block");
404    }
405
406    let n_buckets = params.probs.len(); // = K + 1
407    let mut freq = vec![0u64; n_buckets];
408
409    for block in bits.chunks_exact(params.block_size).take(n_blocks) {
410        let mut max_run = 0usize;
411        let mut cur_run = 0usize;
412        for &bit in block {
413            if bit == 1 {
414                cur_run += 1;
415                if cur_run > max_run {
416                    max_run = cur_run;
417                }
418            } else {
419                cur_run = 0;
420            }
421        }
422        // Find which bucket this max_run belongs to.
423        // Bucket i: max_run ≤ upper_bounds[i] for i < K.
424        // Bucket K (last): max_run > upper_bounds[K-1].
425        let bucket = params
426            .upper_bounds
427            .iter()
428            .position(|&ub| max_run <= ub)
429            .unwrap_or(n_buckets - 1);
430        freq[bucket] += 1;
431    }
432
433    let n_blocks_f = n_blocks as f64;
434    let chi_sq: f64 = freq
435        .iter()
436        .zip(params.probs.iter())
437        .map(|(&observed, &prob)| {
438            if prob > 0.0 {
439                let expected = prob * n_blocks_f;
440                let diff = observed as f64 - expected;
441                diff * diff / expected
442            } else {
443                0.0
444            }
445        })
446        .sum();
447
448    // Degrees of freedom = K (n_buckets - 1).
449    let df = (n_buckets - 1) as f64;
450    let p_value = igamc(df / 2.0, chi_sq / 2.0);
451    let passed = p_value >= 0.01;
452
453    Ok(TestResult {
454        passed,
455        statistic: chi_sq,
456        p_value,
457        description: format!(
458            "NIST Longest Run: M={}, N={n_blocks}, \
459             χ²={chi_sq:.4}, p={p_value:.6} → {}",
460            params.block_size,
461            if passed { "PASS" } else { "FAIL" }
462        ),
463    })
464}
465
466// ---------------------------------------------------------------------------
467// Distribution quality tests
468// ---------------------------------------------------------------------------
469
470/// Chi-squared goodness-of-fit test for uniform distribution.
471///
472/// Divides \[0, 1) into `n_buckets` equal-width bins. Counts how many values
473/// from `samples` (converted to f64 via `x / 2^32`) fall in each bin.
474/// Returns the chi-squared statistic and its p-value (degrees of freedom =
475/// `n_buckets - 1`).
476pub fn uniform_chi_squared(samples: &[u32], n_buckets: usize) -> Result<TestResult, &'static str> {
477    if samples.is_empty() {
478        return Err("uniform_chi_squared: samples must not be empty");
479    }
480    if n_buckets < 2 {
481        return Err("uniform_chi_squared: n_buckets must be >= 2");
482    }
483
484    let mut counts = vec![0u64; n_buckets];
485    let scale = 1.0 / 4_294_967_296.0_f64; // 2^-32
486
487    for &s in samples {
488        let u = s as f64 * scale;
489        let bucket = ((u * n_buckets as f64) as usize).min(n_buckets - 1);
490        counts[bucket] += 1;
491    }
492
493    let expected = samples.len() as f64 / n_buckets as f64;
494    let chi_sq: f64 = counts
495        .iter()
496        .map(|&c| {
497            let d = c as f64 - expected;
498            d * d / expected
499        })
500        .sum();
501
502    let df = (n_buckets - 1) as f64;
503    let p_value = igamc(df / 2.0, chi_sq / 2.0);
504    let passed = p_value >= 0.01;
505
506    Ok(TestResult {
507        passed,
508        statistic: chi_sq,
509        p_value,
510        description: format!(
511            "Uniform χ²: n={}, k={n_buckets}, χ²={chi_sq:.4}, \
512             p={p_value:.6} → {}",
513            samples.len(),
514            if passed { "PASS" } else { "FAIL" }
515        ),
516    })
517}
518
519/// Normal CDF Φ(x) = 0.5 * erfc(-x / √2).
520fn normal_cdf(x: f64) -> f64 {
521    0.5 * erfc(-x / std::f64::consts::SQRT_2)
522}
523
524/// CPU Box-Muller transform matching the PTX implementation.
525///
526/// Given `u1 ∈ (0,1]` and `u2 ∈ [0,1)`, returns the standard normal z0.
527fn box_muller(u1: f64, u2: f64) -> f64 {
528    let u1_safe = u1.max(5.96e-8_f64); // match PTX epsilon
529    let radius = (-2.0 * u1_safe.ln()).sqrt();
530    let angle = 2.0 * std::f64::consts::PI * u2;
531    radius * angle.cos()
532}
533
534/// Kolmogorov-Smirnov test for normality of Box-Muller samples.
535///
536/// Generates `n` normal samples from consecutive Philox blocks using
537/// Box-Muller, then computes the KS statistic
538/// `D = max|F_n(x) - Φ(x)|`.
539///
540/// The critical value at 99% confidence is `1.628 / sqrt(n)`.
541pub fn normal_ks_test(samples: &[u32]) -> Result<TestResult, &'static str> {
542    if samples.len() < 2 {
543        return Err("normal_ks_test: need at least 2 samples (one u1/u2 pair)");
544    }
545    let scale = 1.0 / 4_294_967_296.0_f64;
546    let n_pairs = samples.len() / 2;
547
548    let mut normals: Vec<f64> = (0..n_pairs)
549        .map(|i| {
550            let u1 = samples[2 * i] as f64 * scale;
551            let u2 = samples[2 * i + 1] as f64 * scale;
552            box_muller(u1.max(scale), u2) // ensure u1 > 0
553        })
554        .collect();
555
556    normals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
557    let n = normals.len() as f64;
558
559    let ks_stat = normals
560        .iter()
561        .enumerate()
562        .map(|(i, &x)| {
563            let theoretical = normal_cdf(x);
564            let empirical_hi = (i + 1) as f64 / n;
565            let empirical_lo = i as f64 / n;
566            (empirical_hi - theoretical)
567                .abs()
568                .max((theoretical - empirical_lo).abs())
569        })
570        .fold(0.0_f64, f64::max);
571
572    let critical = 1.628 / n.sqrt();
573    let passed = ks_stat < critical;
574
575    // Approximate p-value via the Kolmogorov distribution asymptotic formula.
576    let lambda = (n.sqrt() + 0.12 + 0.11 / n.sqrt()) * ks_stat;
577    let p_value = {
578        // P-value = 2 * sum_{k=1}^{∞} (-1)^{k+1} * exp(-2*k^2*lambda^2)
579        let mut pv = 0.0_f64;
580        for k in 1..=50_i32 {
581            let sign = if k % 2 == 0 { -1.0 } else { 1.0 };
582            pv += sign * (-2.0 * (k as f64).powi(2) * lambda * lambda).exp();
583        }
584        (2.0 * pv).clamp(0.0, 1.0)
585    };
586
587    Ok(TestResult {
588        passed,
589        statistic: ks_stat,
590        p_value,
591        description: format!(
592            "Normal KS: n={n_pairs}, D={ks_stat:.6}, critical={critical:.6}, \
593             p≈{p_value:.6} → {}",
594            if passed { "PASS" } else { "FAIL" }
595        ),
596    })
597}
598
599/// Test that streams with different counter offsets have low Pearson correlation.
600///
601/// Generates two streams of length `n` starting at offsets 0 and `n`,
602/// computes their Pearson correlation coefficient. For independent streams,
603/// |r| should be < 0.01.
604pub fn independent_streams_correlation(seed: u64, n: usize) -> Result<TestResult, &'static str> {
605    use crate::engines::philox::philox_generate_u32s;
606
607    if n == 0 {
608        return Err("independent_streams_correlation: n must be > 0");
609    }
610
611    let stream_a = philox_generate_u32s(seed, n, 0);
612    let stream_b = philox_generate_u32s(seed, n, n as u64);
613
614    let scale = 1.0 / 4_294_967_296.0_f64;
615    let fa: Vec<f64> = stream_a.iter().map(|&v| v as f64 * scale).collect();
616    let fb: Vec<f64> = stream_b.iter().map(|&v| v as f64 * scale).collect();
617
618    let mean_a = fa.iter().sum::<f64>() / n as f64;
619    let mean_b = fb.iter().sum::<f64>() / n as f64;
620
621    let cov: f64 = fa
622        .iter()
623        .zip(fb.iter())
624        .map(|(&a, &b)| (a - mean_a) * (b - mean_b))
625        .sum::<f64>()
626        / n as f64;
627
628    let var_a: f64 = fa.iter().map(|&a| (a - mean_a).powi(2)).sum::<f64>() / n as f64;
629    let var_b: f64 = fb.iter().map(|&b| (b - mean_b).powi(2)).sum::<f64>() / n as f64;
630
631    let corr = if var_a > 0.0 && var_b > 0.0 {
632        cov / (var_a.sqrt() * var_b.sqrt())
633    } else {
634        0.0
635    };
636
637    let passed = corr.abs() < 0.01;
638
639    Ok(TestResult {
640        passed,
641        statistic: corr,
642        p_value: 1.0 - corr.abs(), // informal metric
643        description: format!(
644            "Independent Streams: n={n}, r={corr:.8} → {}",
645            if passed { "PASS" } else { "FAIL" }
646        ),
647    })
648}
649
650// ---------------------------------------------------------------------------
651// Pure-CPU Box-Muller test using a simple LCG
652// ---------------------------------------------------------------------------
653
654/// Park-Miller LCG: x_{n+1} = 16807 * x_n mod (2^31 - 1).
655///
656/// Returns a uniform sample in (0, 1) by mapping the integer output to the
657/// open interval using the standard `x / (2^31 - 1)` conversion.  Never
658/// returns exactly 0 or 1, which is required by Box-Muller.
659fn lcg_uniform(state: &mut u64) -> f64 {
660    const A: u64 = 16_807;
661    const M: u64 = 2_147_483_647; // 2^31 − 1 (Mersenne prime)
662    *state = (A * *state) % M;
663    *state as f64 / M as f64
664}
665
666/// Box-Muller from LCG: mean and standard-deviation accuracy test.
667///
668/// Generates `n` standard-normal samples from consecutive pairs of LCG
669/// outputs using the standard Box-Muller transform. The empirical mean must
670/// lie within ±0.1 of 0.0 and the empirical standard deviation must be
671/// within 10 % of 1.0.
672///
673/// # Errors
674///
675/// Returns `Err` if `n < 2` (need at least one Box-Muller pair).
676pub fn box_muller_lcg_accuracy(n: usize) -> Result<TestResult, &'static str> {
677    if n < 2 {
678        return Err("box_muller_lcg_accuracy: need at least 2 samples");
679    }
680
681    // Use a fixed non-zero seed for reproducibility.
682    let mut state: u64 = 123_456_789;
683    let mut normals: Vec<f64> = Vec::with_capacity(n);
684
685    while normals.len() < n {
686        let u1 = lcg_uniform(&mut state);
687        let u2 = lcg_uniform(&mut state);
688        // Box-Muller: z0 = sqrt(-2 ln u1) * cos(2π u2)
689        let radius = (-2.0 * u1.ln()).sqrt();
690        let angle = 2.0 * std::f64::consts::PI * u2;
691        normals.push(radius * angle.cos());
692        if normals.len() < n {
693            // Also use the sine output to reduce waste
694            normals.push(radius * angle.sin());
695        }
696    }
697    normals.truncate(n);
698
699    let n_f = n as f64;
700    let mean = normals.iter().sum::<f64>() / n_f;
701    let variance = normals.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / n_f;
702    let std_dev = variance.sqrt();
703
704    // Acceptance criteria: mean ±0.1, std within 10 % of 1.0
705    let mean_ok = mean.abs() < 0.1;
706    let std_ok = (std_dev - 1.0).abs() < 0.1;
707    let passed = mean_ok && std_ok;
708
709    Ok(TestResult {
710        passed,
711        statistic: mean,
712        p_value: if passed { 1.0 } else { 0.0 },
713        description: format!(
714            "Box-Muller LCG: n={n}, mean={mean:.6}, std_dev={std_dev:.6} → {}",
715            if passed { "PASS" } else { "FAIL" }
716        ),
717    })
718}
719
720/// Philox counter-mode independence: verify that sequences at different offsets differ.
721///
722/// Two Philox streams with the same seed but offset by `offset` steps must
723/// produce different values (with overwhelming probability). Also verifies
724/// that non-overlapping windows of the same stream are element-wise distinct
725/// beyond any coincidental single-value match.
726///
727/// # Errors
728///
729/// Returns `Err` if `n == 0` or `offset == 0`.
730pub fn philox_counter_offset_independence(
731    seed: u64,
732    n: usize,
733    offset: u64,
734) -> Result<TestResult, &'static str> {
735    use crate::engines::philox::philox_generate_u32s;
736
737    if n == 0 {
738        return Err("philox_counter_offset_independence: n must be > 0");
739    }
740    if offset == 0 {
741        return Err("philox_counter_offset_independence: offset must be > 0");
742    }
743
744    let seq_a = philox_generate_u32s(seed, n, 0);
745    let seq_b = philox_generate_u32s(seed, n, offset);
746
747    // Count element-wise matches.  For a good PRNG with n=1000 samples drawn
748    // from 2^32 possible values, the expected number of coincidental matches is
749    // n / 2^32 ≈ 0, so any significant number of matches indicates a problem.
750    let matches: usize = seq_a
751        .iter()
752        .zip(seq_b.iter())
753        .filter(|(a, b)| a == b)
754        .count();
755
756    // Also verify the sequences differ at all — they should be entirely distinct.
757    let fraction_matching = matches as f64 / n as f64;
758    // Allow at most 0.1 % coincidental matches (generous bound for a 32-bit PRNG)
759    let passed = fraction_matching < 0.001;
760
761    Ok(TestResult {
762        passed,
763        statistic: fraction_matching,
764        p_value: if passed { 1.0 - fraction_matching } else { 0.0 },
765        description: format!(
766            "Philox counter offset independence: n={n}, offset={offset}, \
767             matches={matches} ({:.4}%) → {}",
768            fraction_matching * 100.0,
769            if passed { "PASS" } else { "FAIL" }
770        ),
771    })
772}
773
774// ---------------------------------------------------------------------------
775// Tests
776// ---------------------------------------------------------------------------
777
778#[cfg(test)]
779mod tests {
780    use super::*;
781    use crate::engines::philox::philox_generate_u32s;
782
783    const SEED: u64 = 42;
784    const N_1M: usize = 1_000_000;
785    const N_10K: usize = 10_000;
786    const N_100K: usize = 100_000;
787
788    // -----------------------------------------------------------------------
789    // Mathematical primitive sanity checks
790    // -----------------------------------------------------------------------
791
792    #[test]
793    fn erfc_known_values() {
794        // erfc(0) = 1.0
795        let v = erfc(0.0);
796        assert!((v - 1.0).abs() < 1e-6, "erfc(0) = {v}");
797        // erfc(∞) → 0
798        let v = erfc(10.0);
799        assert!(v < 1e-20, "erfc(10) = {v}");
800        // erfc(-∞) → 2
801        let v = erfc(-10.0);
802        assert!((v - 2.0).abs() < 1e-20, "erfc(-10) = {v}");
803        // erfc(1) ≈ 0.1572992070
804        let v = erfc(1.0);
805        assert!((v - 0.157_299_207_0).abs() < 1e-6, "erfc(1) = {v}");
806    }
807
808    #[test]
809    fn igamc_known_values() {
810        // igamc(1, 0) = 1.0  (full upper gamma)
811        let v = igamc(1.0, 0.0);
812        assert!((v - 1.0).abs() < 1e-8, "igamc(1,0) = {v}");
813        // igamc(0.5, 0.0) = 1.0
814        let v = igamc(0.5, 0.0);
815        assert!((v - 1.0).abs() < 1e-8, "igamc(0.5,0) = {v}");
816        // For chi-sq with df=2, x=2: p = igamc(1, 1) = exp(-1) ≈ 0.3679
817        let v = igamc(1.0, 1.0);
818        assert!(
819            (v - (-1.0_f64).exp()).abs() < 1e-6,
820            "igamc(1,1) = {v}, expected {}",
821            (-1.0_f64).exp()
822        );
823    }
824
825    // -----------------------------------------------------------------------
826    // NIST frequency test
827    // -----------------------------------------------------------------------
828
829    #[test]
830    fn test_philox_nist_frequency() {
831        let samples = philox_generate_u32s(SEED, N_1M, 0);
832        let result = frequency_test(&samples).expect("frequency_test should not error");
833        assert!(
834            result.passed,
835            "NIST Frequency test FAILED: {}",
836            result.description
837        );
838        assert!(result.p_value >= 0.01, "p_value={} < 0.01", result.p_value);
839    }
840
841    // -----------------------------------------------------------------------
842    // NIST block frequency test
843    // -----------------------------------------------------------------------
844
845    #[test]
846    fn test_philox_nist_block_frequency() {
847        let samples = philox_generate_u32s(SEED, N_1M, 0);
848        // block_size=128 bits (4 u32s per block) — NIST recommends M ≥ 20
849        let result =
850            block_frequency_test(&samples, 128).expect("block_frequency_test should not error");
851        assert!(
852            result.passed,
853            "NIST Block Frequency test FAILED: {}",
854            result.description
855        );
856        assert!(result.p_value >= 0.01, "p_value={} < 0.01", result.p_value);
857    }
858
859    // -----------------------------------------------------------------------
860    // NIST runs test
861    // -----------------------------------------------------------------------
862
863    #[test]
864    fn test_philox_nist_runs() {
865        let samples = philox_generate_u32s(SEED, N_1M, 0);
866        let result = runs_test(&samples).expect("runs_test should not error");
867        assert!(
868            result.passed,
869            "NIST Runs test FAILED: {}",
870            result.description
871        );
872        assert!(result.p_value >= 0.01, "p_value={} < 0.01", result.p_value);
873    }
874
875    // -----------------------------------------------------------------------
876    // NIST longest run test
877    // -----------------------------------------------------------------------
878
879    #[test]
880    fn test_philox_nist_longest_run() {
881        // Need at least 6272 bits = 196 u32s for the M=128 path
882        let samples = philox_generate_u32s(SEED, N_1M, 0);
883        let result = longest_run_test(&samples).expect("longest_run_test should not error");
884        assert!(
885            result.passed,
886            "NIST Longest Run test FAILED: {}",
887            result.description
888        );
889        assert!(result.p_value >= 0.01, "p_value={} < 0.01", result.p_value);
890    }
891
892    // -----------------------------------------------------------------------
893    // Uniform chi-squared goodness-of-fit
894    // -----------------------------------------------------------------------
895
896    #[test]
897    fn test_uniform_chi_squared_goodness_of_fit() {
898        // N = 100_000 samples, 100 buckets.
899        // Expected ≈ 1000 per bucket. χ²(99 df) < 135.8 at 95% conf.
900        let samples = philox_generate_u32s(SEED, N_100K, 0);
901        let result =
902            uniform_chi_squared(&samples, 100).expect("uniform_chi_squared should not error");
903        assert!(
904            result.passed,
905            "Uniform χ² test FAILED: {}",
906            result.description
907        );
908        // Also check raw statistic is in a reasonable range for 99 df
909        assert!(
910            result.statistic < 150.0,
911            "χ²={} suspiciously large for 99 df",
912            result.statistic
913        );
914    }
915
916    // -----------------------------------------------------------------------
917    // Normal KS test
918    // -----------------------------------------------------------------------
919
920    #[test]
921    fn test_normal_kolmogorov_smirnov() {
922        // N_10K pairs → 5000 normal samples. Critical value: 1.628/sqrt(5000) ≈ 0.023
923        let samples = philox_generate_u32s(SEED, N_10K, 0);
924        let result = normal_ks_test(&samples).expect("normal_ks_test should not error");
925        assert!(
926            result.passed,
927            "Normal KS test FAILED: {}",
928            result.description
929        );
930    }
931
932    // -----------------------------------------------------------------------
933    // Independent streams correlation test
934    // -----------------------------------------------------------------------
935
936    #[test]
937    fn test_philox_independent_streams() {
938        let result = independent_streams_correlation(SEED, N_100K)
939            .expect("correlation test should not error");
940        assert!(
941            result.passed,
942            "Independent streams correlation FAILED: {}",
943            result.description
944        );
945        assert!(
946            result.statistic.abs() < 0.01,
947            "|r|={} ≥ 0.01",
948            result.statistic
949        );
950    }
951
952    // -----------------------------------------------------------------------
953    // Error handling
954    // -----------------------------------------------------------------------
955
956    #[test]
957    fn frequency_test_rejects_empty() {
958        assert!(frequency_test(&[]).is_err());
959    }
960
961    #[test]
962    fn block_frequency_test_rejects_empty() {
963        assert!(block_frequency_test(&[], 128).is_err());
964    }
965
966    #[test]
967    fn block_frequency_test_rejects_zero_block_size() {
968        assert!(block_frequency_test(&[1, 2, 3], 0).is_err());
969    }
970
971    #[test]
972    fn runs_test_rejects_empty() {
973        assert!(runs_test(&[]).is_err());
974    }
975
976    #[test]
977    fn longest_run_test_rejects_empty() {
978        assert!(longest_run_test(&[]).is_err());
979    }
980
981    #[test]
982    fn uniform_chi_squared_rejects_empty() {
983        assert!(uniform_chi_squared(&[], 100).is_err());
984    }
985
986    #[test]
987    fn normal_ks_test_rejects_empty() {
988        assert!(normal_ks_test(&[]).is_err());
989    }
990
991    // -----------------------------------------------------------------------
992    // Box-Muller from LCG: numerical accuracy tests
993    // -----------------------------------------------------------------------
994
995    /// 1000 LCG Box-Muller samples: mean ±0.1 of 0.0.
996    #[test]
997    fn test_box_muller_lcg_mean_accuracy() {
998        let result =
999            box_muller_lcg_accuracy(1000).expect("box_muller_lcg_accuracy should not error");
1000        assert!(
1001            result.statistic.abs() < 0.1,
1002            "Box-Muller LCG mean={:.6} not within ±0.1 of 0.0",
1003            result.statistic
1004        );
1005    }
1006
1007    /// 1000 LCG Box-Muller samples: std deviation within 10 % of 1.0.
1008    #[test]
1009    fn test_box_muller_lcg_std_accuracy() {
1010        let mut state: u64 = 123_456_789;
1011        let n = 1000usize;
1012        let mut normals: Vec<f64> = Vec::with_capacity(n);
1013        while normals.len() < n {
1014            let u1 = lcg_uniform(&mut state);
1015            let u2 = lcg_uniform(&mut state);
1016            let radius = (-2.0 * u1.ln()).sqrt();
1017            let angle = 2.0 * std::f64::consts::PI * u2;
1018            normals.push(radius * angle.cos());
1019            if normals.len() < n {
1020                normals.push(radius * angle.sin());
1021            }
1022        }
1023        normals.truncate(n);
1024        let mean = normals.iter().sum::<f64>() / n as f64;
1025        let variance = normals.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / n as f64;
1026        let std_dev = variance.sqrt();
1027        assert!(
1028            (std_dev - 1.0).abs() < 0.1,
1029            "Box-Muller LCG std_dev={std_dev:.6} not within 10% of 1.0"
1030        );
1031    }
1032
1033    /// 1000 LCG Box-Muller samples: combined pass (mean and std accuracy).
1034    #[test]
1035    fn test_box_muller_lcg_combined_accuracy() {
1036        let result =
1037            box_muller_lcg_accuracy(1000).expect("box_muller_lcg_accuracy should not error");
1038        assert!(
1039            result.passed,
1040            "Box-Muller LCG combined accuracy FAILED: {}",
1041            result.description
1042        );
1043    }
1044
1045    /// Error handling: reject n < 2.
1046    #[test]
1047    fn test_box_muller_lcg_rejects_small_n() {
1048        assert!(box_muller_lcg_accuracy(0).is_err());
1049        assert!(box_muller_lcg_accuracy(1).is_err());
1050    }
1051
1052    // -----------------------------------------------------------------------
1053    // Philox counter-mode independence tests
1054    // -----------------------------------------------------------------------
1055
1056    /// Philox stream offset by 1000 steps produces different values.
1057    #[test]
1058    fn test_philox_counter_offset_by_1000() {
1059        let result = philox_counter_offset_independence(SEED, 1000, 1000)
1060            .expect("philox_counter_offset_independence should not error");
1061        assert!(
1062            result.passed,
1063            "Philox offset independence FAILED: {}",
1064            result.description
1065        );
1066        // fraction matching must be essentially zero for a good PRNG
1067        assert!(
1068            result.statistic < 0.001,
1069            "too many matching values: {:.4}%",
1070            result.statistic * 100.0
1071        );
1072    }
1073
1074    /// Philox stream offset by exactly N gives a non-overlapping window.
1075    #[test]
1076    fn test_philox_counter_offset_non_overlapping() {
1077        // Same seed, stream A starts at 0, stream B starts at 500 (no overlap for n=500)
1078        let n = 500usize;
1079        let result = philox_counter_offset_independence(SEED, n, n as u64)
1080            .expect("philox_counter_offset_independence should not error");
1081        assert!(
1082            result.passed,
1083            "Philox non-overlapping window independence FAILED: {}",
1084            result.description
1085        );
1086    }
1087
1088    /// Philox counter independence: different seeds, same offset — sequences differ.
1089    #[test]
1090    fn test_philox_different_seeds_differ() {
1091        let seq_a = philox_generate_u32s(1, 1000, 0);
1092        let seq_b = philox_generate_u32s(2, 1000, 0);
1093        let matches: usize = seq_a
1094            .iter()
1095            .zip(seq_b.iter())
1096            .filter(|(a, b)| a == b)
1097            .count();
1098        let fraction = matches as f64 / 1000.0;
1099        assert!(
1100            fraction < 0.001,
1101            "Different seeds produced {:.4}% matching values — PRNG may be broken",
1102            fraction * 100.0
1103        );
1104    }
1105
1106    /// Error handling: offset=0 is rejected.
1107    #[test]
1108    fn test_philox_counter_offset_rejects_zero_offset() {
1109        assert!(philox_counter_offset_independence(SEED, 100, 0).is_err());
1110    }
1111
1112    /// Error handling: n=0 is rejected.
1113    #[test]
1114    fn test_philox_counter_offset_rejects_zero_n() {
1115        assert!(philox_counter_offset_independence(SEED, 0, 10).is_err());
1116    }
1117}