mod common;
use common::{assert_close, Tol};
use commonstats::transform::{rank, Ties};
fn fixture_path(name: &str) -> String {
format!(
"{}/tests/fixtures/{name}.json",
env!("CARGO_MANIFEST_DIR")
)
}
fn load_str(name: &str) -> String {
let path = fixture_path(name);
std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("missing fixture {path}: {e} — run scripts/gen_oracle.py"))
}
#[derive(serde::Deserialize)]
struct RankRecord {
input: Vec<Option<f64>>,
method: String,
expected: Vec<f64>,
}
fn opt_vec_to_f64(v: &[Option<f64>]) -> Vec<f64> {
v.iter().map(|&x| x.unwrap_or(f64::NAN)).collect()
}
fn method_to_ties(m: &str) -> Ties {
match m {
"average" => Ties::Average,
"min" => Ties::Min,
"max" => Ties::Max,
"dense" => Ties::Dense,
"ordinal" => Ties::Ordinal,
other => panic!("unknown ties method: {other}"),
}
}
#[test]
fn rank_oracle() {
let records: Vec<RankRecord> =
serde_json::from_str(&load_str("rankdata")).expect("rankdata fixture parse");
for r in &records {
let ties = method_to_ties(&r.method);
let input_f64 = opt_vec_to_f64(&r.input);
let got = rank(&input_f64, ties)
.unwrap_or_else(|e| panic!("rank({:?}, {:?}): {:?}", r.input, r.method, e));
assert_eq!(
got.len(),
r.expected.len(),
"rank output length mismatch for input {:?} method {}",
r.input,
r.method
);
for (i, (&g, &w)) in got.iter().zip(r.expected.iter()).enumerate() {
assert_close(
&format!("rank[{}] input={:?} method={}", i, r.input, r.method),
g, w,
Tol { rel: 1e-14, abs: 1e-14 },
);
}
}
}
#[test]
fn rank_empty_is_err() {
use commonstats::error::StatError;
assert_eq!(rank(&[], Ties::Average), Err(StatError::EmptyInput));
}
#[test]
fn rank_all_nan_is_err() {
use commonstats::error::StatError;
assert_eq!(
rank(&[f64::NAN, f64::NAN], Ties::Average),
Err(StatError::AllNaN)
);
}
#[test]
fn rank_nan_omit_length() {
let v = [5.0, f64::NAN, 3.0, f64::NAN, 1.0];
let got = rank(&v, Ties::Average).unwrap();
assert_eq!(got.len(), 3, "expected 3 finite values");
assert_close("rank[0]", got[0], 3.0, Tol { rel: 1e-14, abs: 1e-14 });
assert_close("rank[1]", got[1], 2.0, Tol { rel: 1e-14, abs: 1e-14 });
assert_close("rank[2]", got[2], 1.0, Tol { rel: 1e-14, abs: 1e-14 });
}
use commonstats::transform::box_cox;
#[derive(serde::Deserialize)]
struct PowerRecord {
lambda: f64,
x: f64,
expected: Option<f64>,
}
#[test]
fn boxcox_oracle() {
let records: Vec<PowerRecord> =
serde_json::from_str(&load_str("boxcox")).expect("boxcox fixture parse");
for r in &records {
let result = box_cox(&[r.x], r.lambda);
match r.expected {
None => {
assert!(
result.is_err(),
"box_cox([{}], lambda={}) should be Err, got {:?}",
r.x, r.lambda, result
);
}
Some(w) => {
let got = result
.unwrap_or_else(|e| panic!("box_cox([{}], lambda={}): {:?}", r.x, r.lambda, e));
assert_close(
&format!("box_cox x={} lambda={}", r.x, r.lambda),
got[0], w, Tol { rel: 1e-12, abs: 1e-14 },
);
}
}
}
}
#[test]
fn boxcox_empty_is_err() {
use commonstats::error::StatError;
assert_eq!(box_cox(&[], 1.0), Err(StatError::EmptyInput));
}
#[test]
fn boxcox_all_nan_is_err() {
use commonstats::error::StatError;
assert_eq!(box_cox(&[f64::NAN], 1.0), Err(StatError::AllNaN));
}
#[test]
fn boxcox_domain_error_on_nonpositive() {
use commonstats::error::StatError;
let r = box_cox(&[1.0, 0.0, 2.0], 1.0);
assert!(matches!(r, Err(StatError::DomainError(_))));
}
use commonstats::transform::yeo_johnson;
#[derive(serde::Deserialize)]
struct YjRecord {
lambda: f64,
x: f64,
expected: Option<f64>,
}
#[test]
fn yeojohnson_oracle() {
let records: Vec<YjRecord> =
serde_json::from_str(&load_str("yeojohnson")).expect("yeojohnson fixture parse");
for r in &records {
if let Some(w) = r.expected {
let got = yeo_johnson(&[r.x], r.lambda)
.unwrap_or_else(|e| panic!("yeo_johnson([{}], lambda={}): {:?}", r.x, r.lambda, e));
assert_close(
&format!("yeo_johnson x={} lambda={}", r.x, r.lambda),
got[0], w, Tol { rel: 1e-12, abs: 1e-14 },
);
}
}
}
#[test]
fn yeojohnson_empty_is_err() {
use commonstats::error::StatError;
assert_eq!(yeo_johnson(&[], 1.0), Err(StatError::EmptyInput));
}
#[test]
fn yeojohnson_all_nan_is_err() {
use commonstats::error::StatError;
assert_eq!(yeo_johnson(&[f64::NAN], 1.0), Err(StatError::AllNaN));
}
use commonstats::transform::normal_scores;
#[derive(serde::Deserialize)]
struct NsRecord {
input: Vec<Option<f64>>,
expected: Vec<f64>,
}
#[test]
fn normal_scores_oracle() {
let records: Vec<NsRecord> =
serde_json::from_str(&load_str("normal_scores")).expect("normal_scores fixture parse");
for r in &records {
let input_f64 = opt_vec_to_f64(&r.input);
let got = normal_scores(&input_f64)
.unwrap_or_else(|e| panic!("normal_scores({:?}): {:?}", r.input, e));
assert_eq!(got.len(), r.expected.len(), "length mismatch for {:?}", r.input);
for (i, (&g, &w)) in got.iter().zip(r.expected.iter()).enumerate() {
assert_close(
&format!("normal_scores[{}] input={:?}", i, input_f64),
g, w, Tol { rel: 1e-12, abs: 1e-14 },
);
}
}
}
#[test]
fn normal_scores_empty_is_err() {
use commonstats::error::StatError;
assert_eq!(normal_scores(&[]), Err(StatError::EmptyInput));
}
#[test]
fn normal_scores_all_nan_is_err() {
use commonstats::error::StatError;
assert_eq!(normal_scores(&[f64::NAN, f64::NAN]), Err(StatError::AllNaN));
}
#[test]
fn normal_scores_single_value() {
let got = normal_scores(&[42.0]).unwrap();
assert_close("normal_scores[0]", got[0], 0.0, Tol { rel: 1e-12, abs: 1e-14 });
}
use commonstats::transform::quantile_normalize;
#[derive(serde::Deserialize)]
struct QnFixture {
matrix: Vec<Vec<f64>>,
#[allow(dead_code)]
r#ref: Vec<f64>,
expected: Vec<Vec<f64>>,
}
#[test]
fn quantile_normalize_bolstad_example() {
let fixture: QnFixture =
serde_json::from_str(&load_str("quantile_normalize")).expect("qn fixture parse");
let cols: Vec<&[f64]> = fixture.matrix.iter().map(|c| c.as_slice()).collect();
let got = quantile_normalize(&cols).unwrap();
assert_eq!(got.len(), fixture.expected.len(), "column count mismatch");
for (j, (gc, ec)) in got.iter().zip(fixture.expected.iter()).enumerate() {
assert_eq!(gc.len(), ec.len(), "row count mismatch col {j}");
for (i, (&g, &w)) in gc.iter().zip(ec.iter()).enumerate() {
assert_close(
&format!("qn[col={j}][row={i}]"),
g, w, Tol { rel: 1e-12, abs: 1e-14 },
);
}
}
}
#[test]
fn quantile_normalize_empty_is_err() {
use commonstats::error::StatError;
assert_eq!(quantile_normalize(&[]), Err(StatError::EmptyInput));
}
#[test]
fn quantile_normalize_unequal_columns_is_err() {
use commonstats::error::StatError;
let col0 = [1.0f64, 2.0];
let col1 = [1.0f64, 2.0, 3.0];
let r = quantile_normalize(&[&col0, &col1]);
assert!(matches!(r, Err(StatError::MismatchedLengths { .. })));
}
#[test]
fn quantile_normalize_nan_propagates() {
let col0 = [1.0f64, f64::NAN, 3.0];
let col1 = [2.0f64, 4.0, 6.0];
let got = quantile_normalize(&[&col0, &col1]).unwrap();
assert!(got[0][1].is_nan(), "NaN should propagate at col0[1]");
assert!(got[0][0].is_finite());
assert!(got[0][2].is_finite());
}
#[test]
fn quantile_normalize_shape_preserved() {
let col0 = [1.0f64, 2.0, 3.0];
let col1 = [4.0f64, 5.0, 6.0];
let got = quantile_normalize(&[&col0, &col1]).unwrap();
assert_eq!(got.len(), 2);
assert_eq!(got[0].len(), 3);
assert_eq!(got[1].len(), 3);
}
#[cfg(feature = "dist")]
mod pit_tests {
use commonstats::dist::continuous::Normal;
use commonstats::transform::{inv_pit, pit, quantile_map};
use commonstats::error::StatError;
fn normal() -> Normal {
Normal::new(0.0, 1.0).unwrap()
}
#[test]
fn pit_normal_cdf() {
let n = normal();
let p = pit(0.0, &n);
assert!((p - 0.5).abs() < 1e-12, "pit(0, N(0,1)) should be 0.5, got {p}");
let p2 = pit(1.6448536269514729, &n); assert!((p2 - 0.95).abs() < 1e-4, "pit(1.645, N(0,1)) ≈ 0.95, got {p2}");
}
#[test]
fn inv_pit_normal_quantile() {
let n = normal();
let x = inv_pit(0.5, &n).unwrap();
assert!((x - 0.0).abs() < 1e-10, "inv_pit(0.5, N(0,1)) should be 0, got {x}");
assert!(matches!(inv_pit(-0.1, &n), Err(StatError::ProbabilityOutOfRange(_))));
assert!(matches!(inv_pit(1.1, &n), Err(StatError::ProbabilityOutOfRange(_))));
}
#[test]
fn quantile_map_normal_to_normal() {
let n = normal();
let x = 1.5f64;
let mapped = quantile_map(x, &n, &n).unwrap();
assert!((mapped - x).abs() < 1e-8, "identity map got {mapped}, want {x}");
}
#[test]
fn quantile_map_shifts_distribution() {
let from = Normal::new(0.0, 1.0).unwrap();
let to = Normal::new(1.0, 1.0).unwrap();
let mapped = quantile_map(0.0, &from, &to).unwrap();
assert!((mapped - 1.0).abs() < 1e-8, "shifted map got {mapped}, want 1.0");
}
}