use crate::foundation::{AlgoError, Result};
use statrs::distribution::{ContinuousCDF, Normal as StatrsNormal};
#[derive(Debug, Clone, PartialEq)]
pub struct NormalScore {
values: Vec<f64>,
scores: Vec<f64>,
}
impl NormalScore {
pub fn fit(data: &[f64]) -> Result<NormalScore> {
if data.is_empty() {
return Err(AlgoError::EmptyInput("NormalScore::fit: no data"));
}
let snorm = StatrsNormal::new(0.0, 1.0).expect("standard normal");
let n = data.len();
let mut sorted: Vec<f64> = data.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let all_scores: Vec<f64> = (0..n)
.map(|k| snorm.inverse_cdf((k as f64 + 0.5) / n as f64))
.collect();
let mut values = Vec::new();
let mut scores = Vec::new();
let mut k = 0;
while k < n {
let v = sorted[k];
let mut j = k;
let mut score_sum = 0.0;
while j < n && sorted[j] == v {
score_sum += all_scores[j];
j += 1;
}
values.push(v);
scores.push(score_sum / (j - k) as f64);
k = j;
}
Ok(NormalScore { values, scores })
}
pub fn forward(&self, value: f64) -> f64 {
interp(&self.values, &self.scores, value)
}
pub fn back(&self, score: f64) -> f64 {
interp(&self.scores, &self.values, score)
}
pub fn score_bounds(&self) -> (f64, f64) {
(self.scores[0], self.scores[self.scores.len() - 1])
}
}
fn interp(xs: &[f64], ys: &[f64], x: f64) -> f64 {
let n = xs.len();
if n == 1 || x <= xs[0] {
return ys[0];
}
if x >= xs[n - 1] {
return ys[n - 1];
}
let mut lo = 0;
let mut hi = n - 1;
while hi - lo > 1 {
let mid = (lo + hi) / 2;
if xs[mid] <= x {
lo = mid;
} else {
hi = mid;
}
}
let t = (x - xs[lo]) / (xs[hi] - xs[lo]);
ys[lo] + t * (ys[hi] - ys[lo])
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
#[test]
fn empty_errors() {
assert!(matches!(
NormalScore::fit(&[]),
Err(AlgoError::EmptyInput(_))
));
}
#[test]
fn round_trips_the_data_values() {
let data = [3.0, 1.0, 4.0, 1.5, 9.0, 2.6];
let ns = NormalScore::fit(&data).unwrap();
for &v in &data {
let back = ns.back(ns.forward(v));
assert_relative_eq!(back, v, epsilon = 1e-9);
}
}
#[test]
fn scores_are_symmetric_for_symmetric_ranks() {
let data: Vec<f64> = (1..=100).map(|v| v as f64).collect();
let ns = NormalScore::fit(&data).unwrap();
let mean_score: f64 = data.iter().map(|&v| ns.forward(v)).sum::<f64>() / data.len() as f64;
assert!(mean_score.abs() < 1e-9, "mean score {mean_score} not ~0");
}
#[test]
fn monotone_forward() {
let data = [10.0, 20.0, 30.0, 40.0, 50.0];
let ns = NormalScore::fit(&data).unwrap();
let mut prev = f64::NEG_INFINITY;
for v in [5.0, 12.0, 25.0, 33.0, 48.0, 60.0] {
let s = ns.forward(v);
assert!(s >= prev, "not monotone at {v}");
prev = s;
}
}
#[test]
fn tails_clamp_to_extremes() {
let data = [1.0, 2.0, 3.0];
let ns = NormalScore::fit(&data).unwrap();
let (lo, hi) = ns.score_bounds();
assert_eq!(ns.forward(-100.0), lo);
assert_eq!(ns.forward(100.0), hi);
assert_eq!(ns.back(-100.0), 1.0); assert_eq!(ns.back(100.0), 3.0);
}
#[test]
fn handles_ties() {
let data = [5.0, 5.0, 5.0, 1.0, 9.0];
let ns = NormalScore::fit(&data).unwrap();
let s = ns.forward(5.0);
assert_relative_eq!(ns.back(s), 5.0, epsilon = 1e-9);
}
}