#![expect(
clippy::cast_precision_loss,
reason = "statistics divides by and weights element counts as f64; the count \
would have to exceed 2^52 elements to lose precision, which the \
operation/memory limits make impossible"
)]
use std::cmp::Ordering;
use crate::{
error::{EvalError, EvalResult, InterpreterError},
eval::{control_flow::iterate_value, modules::need_arg},
value::Value,
};
pub fn has_function(name: &str) -> bool {
matches!(
name,
"mean"
| "median"
| "stdev"
| "variance"
| "pstdev"
| "pvariance"
| "mode"
| "quantiles"
| "fmean"
| "median_low"
| "median_high"
| "geometric_mean"
| "harmonic_mean"
| "multimode"
)
}
pub fn call(func: &str, args: &[Value], kwargs: &indexmap::IndexMap<String, Value>) -> EvalResult {
let data = iterate_value(need_arg(func, args, 0)?)?;
match func {
"quantiles" => quantiles(func, &data, kwargs),
"fmean" => {
let nums = numbers(func, &data, 1)?;
Ok(Value::Float(mean(&nums)))
}
"mean" => {
let nums = numbers(func, &data, 1)?;
Ok(coerce(mean(&nums), all_integer(&data)))
}
"median" => median(&data),
"median_low" => median_low_high(&data, false),
"median_high" => median_low_high(&data, true),
"geometric_mean" => {
let nums = numbers(func, &data, 1)?;
if nums.iter().any(|&x| x <= 0.0) {
return Err(crate::eval::modules::statistics_error(
"geometric mean requires a non-empty dataset containing positive numbers",
));
}
let log_mean = nums.iter().map(|x| x.ln()).sum::<f64>() / nums.len() as f64;
Ok(Value::Float(log_mean.exp()))
}
"variance" => {
let nums = numbers("variance", &data, 2)?;
Ok(coerce(variance(&nums, true), all_integer(&data)))
}
"stdev" => Ok(Value::Float(variance(&numbers("stdev", &data, 2)?, true).sqrt())),
"pvariance" => {
let nums = numbers("pvariance", &data, 1)?;
Ok(coerce(variance(&nums, false), all_integer(&data)))
}
"pstdev" => Ok(Value::Float(variance(&numbers("pstdev", &data, 1)?, false).sqrt())),
"mode" => mode(&data),
"multimode" => multimode(&data),
"harmonic_mean" => {
let nums = numbers(func, &data, 1)?;
harmonic_mean(&nums)
}
_ => Err(InterpreterError::AttributeError(format!(
"module 'statistics' has no attribute '{func}'"
))
.into()),
}
}
fn quantiles(func: &str, data: &[Value], kwargs: &indexmap::IndexMap<String, Value>) -> EvalResult {
let n = match kwargs.get("n") {
None => 4usize,
Some(Value::Int(v)) if *v >= 1 => usize::try_from(*v).unwrap_or(4),
Some(_) => {
return Err(crate::eval::modules::statistics_error("n must be at least 1"));
}
};
let inclusive = match kwargs.get("method") {
None => false,
Some(Value::String(s)) if s.as_str() == "inclusive" => true,
Some(Value::String(s)) if s.as_str() == "exclusive" => false,
Some(_) => {
return Err(crate::eval::modules::statistics_error("unknown method"));
}
};
let mut sorted = numbers(func, data, 1)?;
if sorted.len() < 2 {
return Err(crate::eval::modules::statistics_error("must have at least two data points"));
}
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let ld = sorted.len();
let mut result = Vec::with_capacity(n - 1);
if inclusive {
let m = ld - 1;
for i in 1..n {
let j_full = i * m / n;
let delta = i * m - j_full * n;
let interpolated = (sorted[j_full] * (n - delta) as f64
+ sorted[j_full + 1] * delta as f64)
/ n as f64;
result.push(Value::Float(interpolated));
}
} else {
let m = ld + 1;
for i in 1..n {
let mut j = i * m / n;
j = j.clamp(1, ld - 1);
let delta = (i * m) as isize - (j * n) as isize;
let interpolated =
(sorted[j - 1] * (n as isize - delta) as f64 + sorted[j] * delta as f64) / n as f64;
result.push(Value::Float(interpolated));
}
}
Ok(Value::List(crate::value::shared_list(result)))
}
fn coerce(value: f64, all_integer_input: bool) -> Value {
if all_integer_input && value.is_finite() && value.fract() == 0.0 {
if let Some(as_int) = exact_i64(value) {
return Value::Int(as_int);
}
}
Value::Float(value)
}
#[expect(
clippy::cast_possible_truncation,
clippy::float_cmp,
reason = "exact round-trip guard: returns Some only when `i as f64 == value`, where \
exact equality is the intended check and the truncating cast is therefore exact"
)]
fn exact_i64(value: f64) -> Option<i64> {
let as_int = value as i64;
(as_int as f64 == value).then_some(as_int)
}
fn all_integer(data: &[Value]) -> bool {
!data.is_empty() && data.iter().all(|v| matches!(v, Value::Int(_) | Value::Bool(_)))
}
fn numbers(func: &str, data: &[Value], min_required: usize) -> Result<Vec<f64>, EvalError> {
if data.len() < min_required {
let qualifier = if min_required <= 1 { "one data point" } else { "two data points" };
return Err(crate::eval::modules::statistics_error(format!(
"{func} requires at least {qualifier}"
)));
}
data.iter()
.map(|v| {
v.as_float().ok_or_else(|| {
EvalError::from(InterpreterError::TypeError("can't convert value to float".into()))
})
})
.collect()
}
fn mean(data: &[f64]) -> f64 {
let sum: f64 = data.iter().sum();
sum / data.len() as f64
}
fn median(data: &[Value]) -> EvalResult {
if data.is_empty() {
return Err(crate::eval::modules::statistics_error("no median for empty data"));
}
let key = |v: &Value| -> Option<f64> {
match v {
Value::Bool(b) => Some(f64::from(*b)),
_ => v.as_float(),
}
};
for v in data {
if key(v).is_none() {
return Err(InterpreterError::TypeError("can't convert value to float".into()).into());
}
}
let mut ordered: Vec<&Value> = data.iter().collect();
ordered.sort_by(|a, b| {
let av = key(a).unwrap_or(f64::NAN);
let bv = key(b).unwrap_or(f64::NAN);
av.partial_cmp(&bv).unwrap_or(Ordering::Equal)
});
let n = ordered.len();
let mid = n / 2;
if n % 2 == 1 {
Ok(ordered[mid].clone())
} else {
let lo = key(ordered[mid - 1]).unwrap_or(f64::NAN);
let hi = key(ordered[mid]).unwrap_or(f64::NAN);
Ok(Value::Float(f64::midpoint(lo, hi)))
}
}
fn median_low_high(data: &[Value], high: bool) -> EvalResult {
if data.is_empty() {
return Err(crate::eval::modules::statistics_error("no median for empty data"));
}
let key = |v: &Value| -> Option<f64> {
match v {
Value::Bool(b) => Some(f64::from(*b)),
_ => v.as_float(),
}
};
for v in data {
if key(v).is_none() {
return Err(InterpreterError::TypeError("can't convert value to float".into()).into());
}
}
let mut ordered: Vec<&Value> = data.iter().collect();
ordered.sort_by(|a, b| {
key(a)
.unwrap_or(f64::NAN)
.partial_cmp(&key(b).unwrap_or(f64::NAN))
.unwrap_or(Ordering::Equal)
});
let n = ordered.len();
let idx = if n % 2 == 1 || high { n / 2 } else { n / 2 - 1 };
Ok(ordered[idx].clone())
}
fn variance(data: &[f64], sample: bool) -> f64 {
let n = data.len();
let m = mean(data);
let ss: f64 = data.iter().map(|x| (x - m).powi(2)).sum();
let denom = if sample { (n - 1) as f64 } else { n as f64 };
ss / denom
}
fn mode(data: &[Value]) -> EvalResult {
if data.is_empty() {
return Err(crate::eval::modules::statistics_error("no mode for empty data"));
}
let mut counts: indexmap::IndexMap<String, (usize, Value)> = indexmap::IndexMap::new();
for value in data {
let entry = counts.entry(value.repr()).or_insert((0, value.clone()));
entry.0 += 1;
}
let max_count = counts.values().map(|(count, _)| *count).max().unwrap_or(0);
let best = counts
.values()
.find(|(count, _)| *count == max_count)
.map_or(Value::None, |(_, value)| value.clone());
Ok(best)
}
fn multimode(data: &[Value]) -> EvalResult {
let mut counts: indexmap::IndexMap<String, (usize, Value)> = indexmap::IndexMap::new();
for value in data {
counts.entry(value.repr()).or_insert((0, value.clone())).0 += 1;
}
let max_count = counts.values().map(|(count, _)| *count).max().unwrap_or(0);
let modes = counts
.values()
.filter(|(count, _)| *count == max_count)
.map(|(_, value)| value.clone())
.collect();
Ok(Value::List(crate::value::shared_list(modes)))
}
fn harmonic_mean(nums: &[f64]) -> EvalResult {
if nums.iter().any(|&x| x < 0.0) {
return Err(crate::eval::modules::statistics_error(
"harmonic mean does not support negative values",
));
}
if nums.contains(&0.0) {
return Ok(Value::Float(0.0));
}
let sum_recip: f64 = nums.iter().map(|x| 1.0 / x).sum();
Ok(Value::Float(nums.len() as f64 / sum_recip))
}
pub struct StatisticsModule;
#[async_trait::async_trait]
impl crate::eval::modules::Module for StatisticsModule {
fn name(&self) -> &'static str {
"statistics"
}
fn constant(&self, name: &str) -> Option<Value> {
(name == "StatisticsError")
.then(|| Value::ExceptionType("statistics.StatisticsError".to_string()))
}
fn has_function(&self, name: &str) -> bool {
has_function(name)
}
async fn call(
&self,
_state: &mut crate::state::InterpreterState,
func: &str,
args: &[Value],
kwargs: &indexmap::IndexMap<String, Value>,
_tools: &crate::tools::Tools,
) -> EvalResult {
call(func, args, kwargs)
}
}