use std::cell::RefCell;
use std::cmp::Ordering;
#[must_use]
#[allow(clippy::many_single_char_names)]
pub fn fisher_two_tail_pvalue(a: u32, b: u32, c: u32, d: u32) -> Option<f64> {
const MAX_CELL: u32 = i32::MAX as u32;
if a > MAX_CELL || b > MAX_CELL || c > MAX_CELL || d > MAX_CELL {
return None;
}
let n = u64::from(a) + u64::from(b) + u64::from(c) + u64::from(d);
let row1 = u64::from(a) + u64::from(b);
let row2 = u64::from(c) + u64::from(d);
let col1 = u64::from(a) + u64::from(c);
let col2 = u64::from(b) + u64::from(d);
if row1 == 0 || row2 == 0 || col1 == 0 || col2 == 0 {
return None;
}
let log_const =
ln_factorial(row1) + ln_factorial(row2) + ln_factorial(col1) + ln_factorial(col2)
- ln_factorial(n);
let log_pmf = |k: u64| -> f64 {
let bottom_right = (row2 + k) - col1;
let term = ln_factorial(k)
+ ln_factorial(row1 - k)
+ ln_factorial(col1 - k)
+ ln_factorial(bottom_right);
log_const - term
};
let k_min = col1.saturating_sub(row2);
let k_max = row1.min(col1);
let observed = log_pmf(u64::from(a));
let tol = 1e-12;
let mut pvalue = 0.0_f64;
for k in k_min..=k_max {
let lp = log_pmf(k);
if lp <= observed + tol {
pvalue += lp.exp();
}
}
Some(pvalue.min(1.0))
}
#[must_use]
fn ln_factorial(n: u64) -> f64 {
thread_local! {
static TABLE: RefCell<Vec<f64>> = RefCell::new(vec![0.0, 0.0]);
}
TABLE.with(|cell| {
let mut table = cell.borrow_mut();
#[allow(clippy::cast_possible_truncation)]
let idx = n as usize;
if idx >= table.len() {
let mut acc = *table.last().expect("seeded with two entries");
for k in table.len() as u64..=n {
#[allow(clippy::cast_precision_loss)]
{
acc += (k as f64).ln();
}
table.push(acc);
}
}
table[idx]
})
}
#[must_use]
pub fn auc(scored: &[(f64, bool)]) -> Option<f64> {
let n_pos = scored.iter().filter(|(_, label)| *label).count();
let n_neg = scored.len() - n_pos;
if n_pos == 0 || n_neg == 0 {
return None;
}
let mut order: Vec<usize> = (0..scored.len()).collect();
order.sort_by(|&i, &j| scored[i].0.total_cmp(&scored[j].0));
let mut ranks = vec![0.0_f64; scored.len()];
let mut i = 0;
while i < order.len() {
let mut j = i + 1;
while j < order.len()
&& scored[order[j]].0.total_cmp(&scored[order[i]].0) == Ordering::Equal
{
j += 1;
}
#[allow(clippy::cast_precision_loss)]
let avg_rank = (i + 1 + j) as f64 / 2.0;
for &idx in &order[i..j] {
ranks[idx] = avg_rank;
}
i = j;
}
let rank_sum_pos: f64 = scored
.iter()
.zip(&ranks)
.filter_map(|((_, label), &rank)| label.then_some(rank))
.sum();
#[allow(clippy::cast_precision_loss)]
let (n_pos_f, n_neg_f) = (n_pos as f64, n_neg as f64);
let u = rank_sum_pos - n_pos_f * (n_pos_f + 1.0) / 2.0;
Some(u / (n_pos_f * n_neg_f))
}
#[must_use]
pub fn precision_at_k(scored: &[(f64, bool)], k: usize) -> Option<f64> {
if k == 0 || k > scored.len() {
return None;
}
let mut order: Vec<usize> = (0..scored.len()).collect();
order.sort_by(|&i, &j| scored[j].0.total_cmp(&scored[i].0));
let positives = order[..k].iter().filter(|&&idx| scored[idx].1).count();
#[allow(clippy::cast_precision_loss)]
let result = positives as f64 / k as f64;
Some(result)
}
#[must_use]
pub fn bh_fdr_threshold(pvalues: &[f64], q: f64) -> f64 {
let m = pvalues.len();
if m == 0 {
return f64::NEG_INFINITY;
}
let mut sorted = pvalues.to_vec();
sorted.sort_by(f64::total_cmp);
#[allow(clippy::cast_precision_loss)]
let m_f = m as f64;
for k in (1..=m).rev() {
#[allow(clippy::cast_precision_loss)]
let crit = (k as f64 / m_f) * q;
if sorted[k - 1] <= crit {
return sorted[k - 1];
}
}
f64::NEG_INFINITY
}
#[must_use]
pub fn wilson_ci(k: u32, n: u32) -> (f64, f64) {
if n == 0 {
return (0.0, 0.0);
}
let p_hat = f64::from(k) / f64::from(n);
wilson_ci_from_proportion(p_hat, n)
}
#[must_use]
pub fn wilson_ci_from_proportion(p_hat: f64, n: u32) -> (f64, f64) {
if n == 0 {
return (0.0, 0.0);
}
let n = f64::from(n);
let z = 1.96_f64;
let z2 = z * z;
let denom = 1.0 + z2 / n;
let centre = (p_hat + z2 / (2.0 * n)) / denom;
let radius = (z / denom) * (p_hat * (1.0 - p_hat) / n + z2 / (4.0 * n * n)).sqrt();
(
f64::max(0.0, centre - radius),
f64::min(1.0, centre + radius),
)
}
#[cfg(test)]
mod tests {
use super::*;
fn ln_factorial_direct(n: u64) -> f64 {
if n <= 1 {
return 0.0;
}
let mut acc = 0.0_f64;
for i in 2..=n {
#[allow(clippy::cast_precision_loss)]
{
acc += (i as f64).ln();
}
}
acc
}
#[test]
fn ln_factorial_is_bit_identical_to_direct_sum() {
for n in 0..=50u64 {
assert_eq!(
ln_factorial(n).to_bits(),
ln_factorial_direct(n).to_bits(),
"cached ln_factorial({n}) must be bit-identical to the direct sum"
);
}
for &n in &[100u64, 1_000, 100_000] {
assert_eq!(ln_factorial(n).to_bits(), ln_factorial_direct(n).to_bits());
}
assert_eq!(ln_factorial(7).to_bits(), ln_factorial_direct(7).to_bits());
}
fn approx_eq(actual: f64, expected: f64) {
let abs_diff = (actual - expected).abs();
let max_mag = actual.abs().max(expected.abs()).max(1e-300);
let rel = abs_diff / max_mag;
assert!(
rel < 1e-12,
"expected {expected:.15e} got {actual:.15e} (rel diff {rel:.3e})"
);
}
#[test]
fn fisher_matches_upstream_balanced_small() {
let p = fisher_two_tail_pvalue(1, 2, 3, 4).unwrap();
approx_eq(p, 1.000_000_000_000_000_0);
}
#[test]
fn fisher_matches_upstream_classic_significant() {
let p = fisher_two_tail_pvalue(8, 1, 2, 5).unwrap();
approx_eq(p, 3.496_503_496_503_492e-2);
}
#[test]
fn fisher_matches_upstream_highly_significant() {
let p = fisher_two_tail_pvalue(1, 9, 11, 3).unwrap();
approx_eq(p, 2.759_456_185_220_11e-3);
}
#[test]
fn fisher_matches_upstream_symmetric_null() {
let p = fisher_two_tail_pvalue(10, 5, 5, 10).unwrap();
approx_eq(p, 1.431_109_780_507_086e-1);
}
#[test]
fn fisher_matches_upstream_perfect_separation() {
let p = fisher_two_tail_pvalue(0, 5, 5, 0).unwrap();
approx_eq(p, 7.936_507_936_507_943e-3);
}
#[test]
fn fisher_matches_upstream_large_marginals() {
let p = fisher_two_tail_pvalue(100, 50, 50, 100).unwrap();
approx_eq(p, 1.138_235_360_679_261e-8);
}
#[test]
fn fisher_matches_upstream_two_by_two_identity() {
let p = fisher_two_tail_pvalue(1, 0, 0, 1).unwrap();
approx_eq(p, 1.0);
}
#[test]
fn fisher_matches_upstream_perfect_null() {
let p = fisher_two_tail_pvalue(50, 50, 50, 50).unwrap();
approx_eq(p, 1.0);
}
#[test]
fn fisher_returns_none_on_degenerate_marginals() {
assert!(fisher_two_tail_pvalue(0, 0, 5, 5).is_none());
assert!(fisher_two_tail_pvalue(5, 5, 0, 0).is_none());
assert!(fisher_two_tail_pvalue(0, 5, 0, 5).is_none());
assert!(fisher_two_tail_pvalue(5, 0, 5, 0).is_none());
}
#[test]
fn fisher_pvalue_is_bounded() {
for a in 0..=5 {
for b in 0..=5 {
for c in 0..=5 {
for d in 0..=5 {
if let Some(p) = fisher_two_tail_pvalue(a, b, c, d) {
assert!(
(0.0..=1.0).contains(&p),
"out-of-range p={p} for [{a},{b};{c},{d}]"
);
}
}
}
}
}
}
#[test]
fn auc_perfect_separation_is_one() {
let scored = [(0.9, true), (0.8, true), (0.3, false), (0.2, false)];
assert_eq!(auc(&scored), Some(1.0));
}
#[test]
fn auc_reversed_scores_is_zero() {
let scored = [(0.2, true), (0.3, true), (0.8, false), (0.9, false)];
assert_eq!(auc(&scored), Some(0.0));
}
#[test]
fn auc_tie_case_matches_hand_derivation() {
let scored = [(0.9, true), (0.7, true), (0.7, false), (0.1, false)];
assert_eq!(auc(&scored), Some(0.875));
}
#[test]
fn auc_returns_none_when_positive_class_empty() {
let scored = [(0.9, false), (0.1, false)];
assert_eq!(auc(&scored), None);
}
#[test]
fn auc_returns_none_when_negative_class_empty() {
let scored = [(0.9, true), (0.1, true)];
assert_eq!(auc(&scored), None);
}
#[test]
fn precision_at_k_matches_known_top_two() {
let scored = [
(0.9, true),
(0.7, false),
(0.5, true),
(0.3, false),
(0.1, true),
];
assert_eq!(precision_at_k(&scored, 2), Some(0.5));
}
#[test]
fn precision_at_k_breaks_ties_by_input_order() {
let scored = [(0.5, true), (0.5, false), (0.5, true)];
assert_eq!(precision_at_k(&scored, 2), Some(0.5));
}
#[test]
fn precision_at_k_returns_none_for_k_zero() {
let scored = [(0.9, true), (0.1, false)];
assert_eq!(precision_at_k(&scored, 0), None);
}
#[test]
fn precision_at_k_returns_none_when_k_exceeds_len() {
let scored = [(0.9, true), (0.1, false)];
assert_eq!(precision_at_k(&scored, 3), None);
}
#[test]
fn bh_fdr_threshold_matches_hand_computed() {
fn bits_eq(a: f64, b: f64) {
assert_eq!(a.to_bits(), b.to_bits(), "expected {b}, got {a}");
}
let p = [0.001, 0.008, 0.039, 0.041, 0.9];
bits_eq(bh_fdr_threshold(&p, 0.05), 0.008);
bits_eq(bh_fdr_threshold(&[], 0.05), f64::NEG_INFINITY);
bits_eq(bh_fdr_threshold(&[0.9, 0.95], 0.05), f64::NEG_INFINITY);
bits_eq(bh_fdr_threshold(&[0.001, 0.002], 0.05), 0.002);
let shuffled = [0.9, 0.041, 0.001, 0.039, 0.008];
bits_eq(
bh_fdr_threshold(&shuffled, 0.05),
bh_fdr_threshold(&p, 0.05),
);
}
#[test]
fn wilson_ci_k_zero() {
let (lo, hi) = wilson_ci(0, 100);
assert!(lo >= 0.0, "lo must be ≥ 0: {lo}");
assert!(
hi > 0.0 && hi < 0.05,
"hi for k=0/n=100 should be small: {hi}"
);
}
#[test]
fn wilson_ci_k_equals_n() {
let (lo, hi) = wilson_ci(100, 100);
assert!(lo > 0.95 && lo <= 1.0, "lo for k=n should be near 1: {lo}");
assert!(
(hi - 1.0).abs() < 1e-9,
"hi for k=n should be exactly 1: {hi}"
);
}
#[test]
fn wilson_ci_half() {
let (lo, hi) = wilson_ci(50, 100);
assert!(lo > 0.39 && lo < 0.50, "lo for k=50/n=100: {lo}");
assert!(hi > 0.50 && hi < 0.61, "hi for k=50/n=100: {hi}");
assert!(lo < hi, "interval must be non-empty");
}
#[test]
fn wilson_ci_n_zero_returns_zeros() {
let (lo, hi) = wilson_ci(0, 0);
assert_eq!((lo, hi), (0.0, 0.0));
}
#[test]
fn wilson_ci_interval_contains_p_hat() {
let k = 30_u32;
let n = 100_u32;
let (lo, hi) = wilson_ci(k, n);
let p_hat = f64::from(k) / f64::from(n);
assert!(
lo <= p_hat && p_hat <= hi,
"interval must contain p_hat={p_hat}: [{lo}, {hi}]"
);
}
#[test]
fn wilson_ci_from_proportion_matches_integer_form() {
let (a_lo, a_hi) = wilson_ci(30, 100);
let (b_lo, b_hi) = wilson_ci_from_proportion(0.30, 100);
assert!((a_lo - b_lo).abs() < 1e-12, "{a_lo} vs {b_lo}");
assert!((a_hi - b_hi).abs() < 1e-12, "{a_hi} vs {b_hi}");
}
#[test]
fn wilson_ci_from_proportion_wraps_midpoint_rank_small_n() {
let p_hat = 2.0_f64 / 3.0;
let (lo, hi) = wilson_ci_from_proportion(p_hat, 3);
assert!(
lo <= p_hat && p_hat <= hi,
"must contain p_hat: [{lo}, {hi}]"
);
assert!((0.20..0.22).contains(&lo), "lo ≈ 0.208: {lo}");
assert!((0.93..0.95).contains(&hi), "hi ≈ 0.939: {hi}");
assert!(hi - lo > 0.6, "n=3 interval must be wide: {}", hi - lo);
}
#[test]
fn wilson_ci_from_proportion_saturates_at_one() {
let (lo, hi) = wilson_ci_from_proportion(1.0, 2);
assert!((0.33..0.35).contains(&lo), "lo ≈ 0.342: {lo}");
assert!((hi - 1.0).abs() < 1e-9, "hi must clamp to 1.0: {hi}");
}
#[test]
fn wilson_ci_from_proportion_n_zero_returns_zeros() {
assert_eq!(wilson_ci_from_proportion(0.5, 0), (0.0, 0.0));
}
#[test]
fn wilson_ci_from_proportion_wider_for_smaller_n() {
let (lo_small, hi_small) = wilson_ci_from_proportion(0.5, 10);
let (lo_big, hi_big) = wilson_ci_from_proportion(0.5, 1000);
assert!(
(hi_small - lo_small) > (hi_big - lo_big),
"n=10 width {} must exceed n=1000 width {}",
hi_small - lo_small,
hi_big - lo_big
);
}
}