use crate::error::{Result, StatError};
use crate::parametric::Alternative;
use crate::utils::math::{mean, variance};
use rand::seq::SliceRandom;
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
#[inline]
fn is_extreme(perm_stat: f64, observed: f64, alternative: Alternative) -> bool {
match alternative {
Alternative::TwoSided => perm_stat.abs() >= observed.abs(),
Alternative::Greater => perm_stat >= observed,
Alternative::Less => perm_stat <= observed,
}
}
fn welch_t_statistic(a: &[f64], b: &[f64]) -> f64 {
let mean_a = mean(a).unwrap_or(0.0);
let mean_b = mean(b).unwrap_or(0.0);
let var_a = variance(a).unwrap_or(1.0);
let var_b = variance(b).unwrap_or(1.0);
let n_a = a.len() as f64;
let n_b = b.len() as f64;
let se = (var_a / n_a + var_b / n_b).sqrt();
if se < 1e-14 {
0.0
} else {
(mean_a - mean_b) / se
}
}
fn validate_permutation_inputs(x: &[f64], y: &[f64]) -> Result<()> {
if x.is_empty() || y.is_empty() {
return Err(StatError::EmptyData);
}
if x.len() < 2 {
return Err(StatError::InsufficientData {
needed: 2,
got: x.len(),
});
}
if y.len() < 2 {
return Err(StatError::InsufficientData {
needed: 2,
got: y.len(),
});
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct PermutationResult {
pub statistic: f64,
pub p_value: f64,
pub n_permutations: usize,
}
pub struct PermutationEngine {
n_permutations: usize,
seed: Option<u64>,
}
impl PermutationEngine {
pub fn new(n_permutations: usize) -> Self {
Self {
n_permutations,
seed: None,
}
}
pub fn with_seed(mut self, seed: u64) -> Self {
self.seed = Some(seed);
self
}
pub fn run<F>(
&self,
x: &[f64],
y: &[f64],
statistic_fn: F,
alternative: Alternative,
) -> Result<PermutationResult>
where
F: Fn(&[f64], &[f64]) -> f64,
{
if x.is_empty() || y.is_empty() {
return Err(StatError::EmptyData);
}
let n1 = x.len();
let n_total = x.len() + y.len();
let observed = statistic_fn(x, y);
let mut combined: Vec<f64> = x.iter().chain(y.iter()).cloned().collect();
let mut rng = match self.seed {
Some(seed) => ChaCha8Rng::seed_from_u64(seed),
None => ChaCha8Rng::from_entropy(),
};
let mut count_extreme = 0usize;
for _ in 0..self.n_permutations {
combined.shuffle(&mut rng);
let perm_x = &combined[0..n1];
let perm_y = &combined[n1..n_total];
let perm_stat = statistic_fn(perm_x, perm_y);
if is_extreme(perm_stat, observed, alternative) {
count_extreme += 1;
}
}
let p_value = (count_extreme as f64 + 1.0) / (self.n_permutations as f64 + 1.0);
Ok(PermutationResult {
statistic: observed,
p_value,
n_permutations: self.n_permutations,
})
}
}
pub fn permutation_t_test(
x: &[f64],
y: &[f64],
alternative: Alternative,
n_permutations: usize,
seed: Option<u64>,
) -> Result<PermutationResult> {
validate_permutation_inputs(x, y)?;
let mut engine = PermutationEngine::new(n_permutations);
if let Some(s) = seed {
engine = engine.with_seed(s);
}
engine.run(x, y, welch_t_statistic, alternative)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_permutation_t_test_different_samples() {
let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let y = vec![10.0, 11.0, 12.0, 13.0, 14.0];
let result = permutation_t_test(&x, &y, Alternative::TwoSided, 999, Some(42)).unwrap();
assert!(
result.p_value < 0.05,
"p_value {} should be < 0.05",
result.p_value
);
}
#[test]
fn test_permutation_t_test_similar_samples() {
let x = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
let y = vec![1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5, 9.5, 10.5];
let result = permutation_t_test(&x, &y, Alternative::TwoSided, 999, Some(42)).unwrap();
assert!(
result.p_value > 0.1,
"p_value {} should be > 0.1",
result.p_value
);
}
#[test]
fn test_permutation_t_test_reproducibility() {
let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let y = vec![2.0, 3.0, 4.0, 5.0, 6.0];
let result1 = permutation_t_test(&x, &y, Alternative::TwoSided, 999, Some(12345)).unwrap();
let result2 = permutation_t_test(&x, &y, Alternative::TwoSided, 999, Some(12345)).unwrap();
assert_eq!(result1.statistic, result2.statistic);
assert_eq!(result1.p_value, result2.p_value);
}
#[test]
fn test_permutation_t_test_empty() {
let x: Vec<f64> = vec![];
let y = vec![1.0, 2.0, 3.0];
assert!(permutation_t_test(&x, &y, Alternative::TwoSided, 100, None).is_err());
}
#[test]
fn test_permutation_t_test_insufficient() {
let x = vec![1.0];
let y = vec![1.0, 2.0, 3.0];
assert!(permutation_t_test(&x, &y, Alternative::TwoSided, 100, None).is_err());
}
#[test]
fn test_permutation_engine_custom_statistic() {
let x = vec![1.0, 2.0, 3.0, 4.0, 5.0, 1.5, 2.5, 3.5, 4.5, 5.5];
let y = vec![10.0, 11.0, 12.0, 13.0, 14.0, 10.5, 11.5, 12.5, 13.5, 14.5];
let median_diff = |a: &[f64], b: &[f64]| -> f64 {
let mut a_sorted: Vec<f64> = a.to_vec();
let mut b_sorted: Vec<f64> = b.to_vec();
a_sorted.sort_by(|x, y| x.partial_cmp(y).unwrap());
b_sorted.sort_by(|x, y| x.partial_cmp(y).unwrap());
let median_a = if a_sorted.len() % 2 == 1 {
a_sorted[a_sorted.len() / 2]
} else {
(a_sorted[a_sorted.len() / 2 - 1] + a_sorted[a_sorted.len() / 2]) / 2.0
};
let median_b = if b_sorted.len() % 2 == 1 {
b_sorted[b_sorted.len() / 2]
} else {
(b_sorted[b_sorted.len() / 2 - 1] + b_sorted[b_sorted.len() / 2]) / 2.0
};
median_a - median_b
};
let engine = PermutationEngine::new(999).with_seed(42);
let result = engine
.run(&x, &y, median_diff, Alternative::TwoSided)
.unwrap();
assert!(
result.p_value < 0.05,
"p_value {} should be < 0.05",
result.p_value
);
}
}