use super::{apply_policy, MissingDataPolicy, TransformError};
pub fn quantile(values: &[f64], q: f64, missing: MissingDataPolicy) -> Result<f64, TransformError> {
let working = apply_policy(values, missing)?;
if working.is_empty() {
return Err(TransformError::EmptyInput);
}
if super::contains_nan(&working) {
return Ok(f64::NAN);
}
let mut sorted = working;
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
Ok(percentile_linear(&sorted, q))
}
pub(crate) fn percentile_linear(sorted: &[f64], q: f64) -> f64 {
let n = sorted.len();
if n == 1 {
return sorted[0];
}
let rank = q.clamp(0.0, 1.0) * (n - 1) as f64;
let lo = rank.floor() as usize;
let hi = rank.ceil() as usize;
if lo == hi {
sorted[lo]
} else {
let frac = rank - lo as f64;
sorted[lo] * (1.0 - frac) + sorted[hi] * frac
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn matches_the_numpy_linear_method_golden() {
assert!((quantile(&[1.0, 2.0, 3.0, 4.0], 0.25, MissingDataPolicy::Skip).unwrap() - 1.75).abs() < 1e-9);
assert!((quantile(&[1.0, 2.0, 3.0, 4.0], 0.5, MissingDataPolicy::Skip).unwrap() - 2.5).abs() < 1e-9);
assert!((quantile(&[1.0, 2.0, 3.0, 4.0], 0.75, MissingDataPolicy::Skip).unwrap() - 3.25).abs() < 1e-9);
}
#[test]
fn n_equals_1_collapses_to_the_single_sample_for_any_q() {
for q in [0.0, 0.25, 0.5, 0.75, 1.0] {
assert_eq!(quantile(&[42.0], q, MissingDataPolicy::Skip).unwrap(), 42.0);
}
}
#[test]
fn n_equals_2_interpolates_between_the_two_real_samples() {
assert!((quantile(&[1.0, 2.0], 0.25, MissingDataPolicy::Skip).unwrap() - 1.25).abs() < 1e-9);
assert!((quantile(&[1.0, 2.0], 0.5, MissingDataPolicy::Skip).unwrap() - 1.5).abs() < 1e-9);
}
#[test]
fn is_order_independent_sorts_internally() {
let a = quantile(&[4.0, 1.0, 3.0, 2.0], 0.5, MissingDataPolicy::Skip).unwrap();
let b = quantile(&[1.0, 2.0, 3.0, 4.0], 0.5, MissingDataPolicy::Skip).unwrap();
assert_eq!(a, b);
}
#[test]
fn q_is_clamped_to_0_1() {
let values = [1.0, 2.0, 3.0];
assert_eq!(quantile(&values, -1.0, MissingDataPolicy::Skip).unwrap(), quantile(&values, 0.0, MissingDataPolicy::Skip).unwrap());
assert_eq!(quantile(&values, 5.0, MissingDataPolicy::Skip).unwrap(), quantile(&values, 1.0, MissingDataPolicy::Skip).unwrap());
}
#[test]
fn empty_input_is_an_explicit_error_never_a_panic() {
assert_eq!(quantile(&[], 0.5, MissingDataPolicy::Skip), Err(TransformError::EmptyInput));
assert_eq!(quantile(&[], 0.5, MissingDataPolicy::Propagate), Err(TransformError::EmptyInput));
}
#[test]
fn skip_policy_filters_non_finite_values_before_computing() {
let with_nan = quantile(&[1.0, f64::NAN, 2.0, 3.0, 4.0], 0.5, MissingDataPolicy::Skip).unwrap();
let clean = quantile(&[1.0, 2.0, 3.0, 4.0], 0.5, MissingDataPolicy::Skip).unwrap();
assert_eq!(with_nan, clean);
}
#[test]
fn skip_policy_all_non_finite_is_empty_input_error() {
assert_eq!(quantile(&[f64::NAN, f64::NAN], 0.5, MissingDataPolicy::Skip), Err(TransformError::EmptyInput));
}
#[test]
fn propagate_policy_with_nan_present_returns_nan_without_panicking() {
let result = quantile(&[1.0, f64::NAN, 3.0], 0.5, MissingDataPolicy::Propagate).unwrap();
assert!(result.is_nan());
}
#[test]
fn propagate_policy_with_only_infinity_still_interpolates_normally() {
let result = quantile(&[1.0, f64::INFINITY, 3.0], 1.0, MissingDataPolicy::Propagate).unwrap();
assert!(result.is_infinite() && result > 0.0);
}
#[test]
fn error_policy_reports_the_offending_index() {
let err = quantile(&[1.0, 2.0, f64::NAN], 0.5, MissingDataPolicy::Error).unwrap_err();
assert_eq!(err, TransformError::NonFinite { index: 2 });
}
#[test]
fn all_equal_samples_return_the_constant_value_for_every_q() {
let values = [5.0; 10];
for q in [0.0, 0.25, 0.5, 0.75, 1.0] {
assert_eq!(quantile(&values, q, MissingDataPolicy::Skip).unwrap(), 5.0);
}
}
}