#![forbid(unsafe_code)]
use std::process::Stdio;
use fsci_conformance::CompareLedger;
use fsci_stats::{PowerResult, power, power_simulate, ttest_1samp};
use serde::Deserialize;
const REQUIRE_SCIPY_ENV: &str = "FSCI_REQUIRE_SCIPY_ORACLE";
const POWER_SE_TOL: f64 = 3.0;
const N_OBS: usize = 20;
const N_RESAMPLES: usize = 20_000;
const EFFECT: f64 = 0.5;
struct Normal {
state: u64,
spare: Option<f64>,
}
impl Normal {
fn new(seed: u64) -> Self {
Self {
state: seed,
spare: None,
}
}
fn uniform(&mut self) -> f64 {
self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^= z >> 31;
((z >> 11) + 1) as f64 / (1_u64 << 53) as f64
}
fn next(&mut self) -> f64 {
if let Some(v) = self.spare.take() {
return v;
}
let (u1, u2) = (self.uniform(), self.uniform());
let r = (-2.0 * u1.ln()).sqrt();
let theta = std::f64::consts::TAU * u2;
self.spare = Some(r * theta.sin());
r * theta.cos()
}
}
fn sampler(seed: u64, mean: f64) -> impl FnMut(usize) -> Vec<f64> {
let mut normal = Normal::new(seed);
move |n| (0..n).map(|_| mean + normal.next()).collect()
}
fn t_test(sample: &[f64]) -> f64 {
ttest_1samp(sample, 0.0).pvalue
}
fn se(p: f64) -> f64 {
(p * (1.0 - p) / N_RESAMPLES as f64).sqrt()
}
#[derive(Debug, Deserialize)]
struct Oracle {
analytic_05: f64,
analytic_01: f64,
scipy_power_05: f64,
scipy_size_05: f64,
}
fn scipy_oracle() -> Option<Oracle> {
let script = r#"
import json
import numpy as np
from scipy import stats
n, eff, R = 20, 0.5, 20000
def analytic(alpha):
tc = stats.t.ppf(1 - alpha / 2, n - 1)
nc = eff * np.sqrt(n)
return float(stats.nct.sf(tc, n - 1, nc) + stats.nct.cdf(-tc, n - 1, nc))
test = lambda x, axis=-1: stats.ttest_1samp(x, 0.0, axis=axis).pvalue
rng = np.random.default_rng(12345)
alt = stats.power(test, lambda size: rng.normal(eff, 1.0, size=size), n,
significance=0.05, n_resamples=R, vectorized=True)
rng0 = np.random.default_rng(54321)
null = stats.power(test, lambda size: rng0.normal(0.0, 1.0, size=size), n,
significance=0.05, n_resamples=R, vectorized=True)
print(json.dumps({"analytic_05": analytic(0.05), "analytic_01": analytic(0.01),
"scipy_power_05": float(alt.power), "scipy_size_05": float(null.power)}))
"#;
let mut child = match fsci_conformance::scipy_oracle_command()
.arg("-c")
.arg(script)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => {
assert!(
std::env::var(REQUIRE_SCIPY_ENV).is_err(),
"failed to spawn the power oracle: {e}"
);
eprintln!("skipping power oracle: python not available ({e})");
return None;
}
};
drop(child.stdin.take()); let output = child.wait_with_output().expect("wait for the power oracle");
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
std::env::var(REQUIRE_SCIPY_ENV).is_err(),
"power oracle failed: {stderr}"
);
eprintln!("skipping power oracle: scipy not available\n{stderr}");
return None;
}
Some(serde_json::from_slice(&output.stdout).expect("parse power oracle JSON"))
}
#[test]
fn power_is_deterministic_for_a_seed() {
let run = |seed| power_simulate(t_test, sampler(seed, EFFECT), N_OBS, 2_000, 0.05);
let (a, b, c): (PowerResult, PowerResult, PowerResult) = (run(7), run(7), run(8));
let bits = |r: &PowerResult| r.pvalues.iter().map(|p| p.to_bits()).collect::<Vec<_>>();
assert_eq!(
bits(&a),
bits(&b),
"same seed must give bit-identical p-values"
);
assert_eq!(a.power.to_bits(), b.power.to_bits());
assert_ne!(
bits(&a),
bits(&c),
"a different seed gave the same p-values"
);
}
#[test]
fn diff_stats_power() {
let Some(oracle) = scipy_oracle() else {
return;
};
let alt = power_simulate(t_test, sampler(2026, EFFECT), N_OBS, N_RESAMPLES, 0.05);
let null = power_simulate(t_test, sampler(2027, 0.0), N_OBS, N_RESAMPLES, 0.05);
let default_level = power(t_test, sampler(2028, EFFECT), N_OBS, N_RESAMPLES);
let rows = [
(
"fsci power @0.05 vs noncentral-t",
alt.power,
oracle.analytic_05,
se(oracle.analytic_05),
),
(
"SciPy power @0.05 vs noncentral-t (oracle sanity)",
oracle.scipy_power_05,
oracle.analytic_05,
se(oracle.analytic_05),
),
(
"fsci vs scipy.stats.power @0.05",
alt.power,
oracle.scipy_power_05,
std::f64::consts::SQRT_2 * se(oracle.analytic_05),
),
("fsci size under N(0,1) vs 0.05", null.power, 0.05, se(0.05)),
(
"SciPy size under N(0,1) vs 0.05",
oracle.scipy_size_05,
0.05,
se(0.05),
),
(
"fsci power() default significance 0.01 vs noncentral-t",
default_level.power,
oracle.analytic_01,
se(oracle.analytic_01),
),
];
let mut ledger = CompareLedger::new("diff_stats_power", &["power"]);
let mut failures = Vec::new();
for (label, estimate, target, sigma) in rows {
let Some((target, estimate)) = ledger.pair("power", label, Some(target), Some(estimate))
else {
continue;
};
let z = (estimate - target).abs() / sigma;
println!("{label}: {estimate:.5} vs {target:.5} ({z:.2} SE)");
let fail = z.is_nan() || z > POWER_SE_TOL;
ledger.compared("power", label, !fail);
if fail {
failures.push(format!("{label}: {estimate} vs {target} ({z:.2} SE)"));
}
}
assert_eq!(alt.pvalues.len(), N_RESAMPLES);
assert!(failures.is_empty(), "power disagrees: {failures:#?}");
ledger.finish(rows.len());
}