use super::linear::format_value;
use std::cmp::Ordering;
pub trait ClassScale {
fn class_count(&self) -> usize;
fn class_index(&self, v: f64) -> usize;
fn class_label(&self, index: usize) -> String;
}
fn label_step(lo: f64, hi: f64) -> f64 {
let span = (hi - lo).abs();
if span > f64::EPSILON {
span / 10.0
} else {
1.0
}
}
fn bounded_class_label(domain_min: f64, domain_max: f64, thresholds: &[f64], index: usize) -> String {
let lo = if index == 0 { domain_min } else { *thresholds.get(index - 1).unwrap_or(&domain_min) };
let hi = *thresholds.get(index).unwrap_or(&domain_max);
let step = label_step(lo, hi);
format!("{} – {}", format_value(lo, step), format_value(hi, step))
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct QuantizeScale {
pub min: f64,
pub max: f64,
n: usize,
}
impl QuantizeScale {
pub fn new(min: f64, max: f64, n: usize) -> Self {
Self { min, max, n: n.max(1) }
}
fn width(&self) -> f64 {
let range = self.max - self.min;
if range.abs() < f64::EPSILON || !range.is_finite() {
0.0
} else {
range / self.n as f64
}
}
pub fn thresholds(&self) -> Vec<f64> {
let w = self.width();
if w <= 0.0 {
return Vec::new();
}
(1..self.n).map(|i| self.min + w * i as f64).collect()
}
}
impl ClassScale for QuantizeScale {
fn class_count(&self) -> usize {
self.n
}
fn class_index(&self, v: f64) -> usize {
if !v.is_finite() || self.max <= self.min || !self.min.is_finite() || !self.max.is_finite() {
return 0;
}
let w = self.width();
if w <= 0.0 {
return 0;
}
let idx = ((v - self.min) / w).floor();
if idx < 0.0 {
0
} else {
(idx as usize).min(self.n - 1)
}
}
fn class_label(&self, index: usize) -> String {
bounded_class_label(self.min, self.max, &self.thresholds(), index)
}
}
#[derive(Debug, Clone)]
pub struct ThresholdScale {
thresholds: Vec<f64>,
}
impl ThresholdScale {
pub fn new(thresholds: Vec<f64>) -> Self {
let mut thresholds: Vec<f64> = thresholds.into_iter().filter(|v| v.is_finite()).collect();
thresholds.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
thresholds.dedup_by(|a, b| (*a - *b).abs() < f64::EPSILON);
Self { thresholds }
}
pub fn thresholds(&self) -> &[f64] {
&self.thresholds
}
}
impl ClassScale for ThresholdScale {
fn class_count(&self) -> usize {
self.thresholds.len() + 1
}
fn class_index(&self, v: f64) -> usize {
if !v.is_finite() {
return 0;
}
self.thresholds.iter().filter(|&&t| v >= t).count()
}
fn class_label(&self, index: usize) -> String {
let t = &self.thresholds;
if t.is_empty() {
return "all".to_owned();
}
let step = label_step(t[0], *t.last().unwrap_or(&t[0])).max(f64::EPSILON);
if index == 0 {
format!("< {}", format_value(t[0], step))
} else if index >= t.len() {
format!(">= {}", format_value(t[t.len() - 1], step))
} else {
format!("{} – {}", format_value(t[index - 1], step), format_value(t[index], step))
}
}
}
fn percentile(sorted: &[f64], p: f64) -> f64 {
let n = sorted.len();
if n == 0 {
return f64::NAN;
}
if n == 1 {
return sorted[0];
}
let rank = p.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
}
}
#[derive(Debug, Clone)]
pub struct QuantileScale {
breaks: Vec<f64>,
data_min: f64,
data_max: f64,
}
impl QuantileScale {
pub fn new(data: &[f64], n: usize) -> Self {
let n = n.max(1);
let mut sorted: Vec<f64> = data.iter().copied().filter(|v| v.is_finite()).collect();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
if sorted.len() <= 1 || n <= 1 {
let anchor = sorted.first().copied().unwrap_or(0.0);
let top = sorted.last().copied().unwrap_or(anchor + 1.0);
return Self { breaks: Vec::new(), data_min: anchor, data_max: top };
}
let breaks: Vec<f64> = (1..n).map(|i| percentile(&sorted, i as f64 / n as f64)).collect();
Self { breaks, data_min: sorted[0], data_max: *sorted.last().unwrap_or(&sorted[0]) }
}
}
impl ClassScale for QuantileScale {
fn class_count(&self) -> usize {
self.breaks.len() + 1
}
fn class_index(&self, v: f64) -> usize {
if !v.is_finite() {
return 0;
}
self.breaks.iter().filter(|&&b| v >= b).count()
}
fn class_label(&self, index: usize) -> String {
bounded_class_label(self.data_min, self.data_max, &self.breaks, index)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quantize_uniform_bins_split_the_domain_into_equal_widths() {
let scale = QuantizeScale::new(0.0, 100.0, 4);
assert_eq!(scale.thresholds(), vec![25.0, 50.0, 75.0]);
}
#[test]
fn quantize_class_index_at_boundaries_and_interior() {
let scale = QuantizeScale::new(0.0, 100.0, 4);
assert_eq!(scale.class_index(0.0), 0);
assert_eq!(scale.class_index(24.9), 0);
assert_eq!(scale.class_index(25.0), 1);
assert_eq!(scale.class_index(49.9), 1);
assert_eq!(scale.class_index(75.0), 3);
assert_eq!(scale.class_index(100.0), 3);
assert_eq!(scale.class_index(1_000_000.0), 3);
}
#[test]
fn quantize_negative_domain_classifies_correctly() {
let scale = QuantizeScale::new(-100.0, 100.0, 2);
assert_eq!(scale.class_index(-50.0), 0);
assert_eq!(scale.class_index(50.0), 1);
assert_eq!(scale.class_index(0.0), 1); }
#[test]
fn quantize_degenerate_domain_never_panics_and_stays_in_class_zero() {
let scale = QuantizeScale::new(5.0, 5.0, 4);
assert_eq!(scale.class_index(5.0), 0);
assert_eq!(scale.class_count(), 4);
assert!(scale.thresholds().is_empty());
assert!(!scale.class_label(0).is_empty());
}
#[test]
fn quantize_n_zero_floors_to_one_class() {
let scale = QuantizeScale::new(0.0, 10.0, 0);
assert_eq!(scale.class_count(), 1);
assert_eq!(scale.class_index(5.0), 0);
}
#[test]
fn quantize_class_label_reflects_bin_bounds() {
let scale = QuantizeScale::new(0.0, 100.0, 4);
assert!(scale.class_label(0).contains('0'));
assert!(scale.class_label(3).contains("100"));
}
#[test]
fn threshold_explicit_cut_points_partition_into_n_plus_one_classes() {
let scale = ThresholdScale::new(vec![10.0, 20.0, 30.0]);
assert_eq!(scale.class_count(), 4);
assert_eq!(scale.class_index(5.0), 0);
assert_eq!(scale.class_index(10.0), 1);
assert_eq!(scale.class_index(25.0), 2);
assert_eq!(scale.class_index(30.0), 3);
assert_eq!(scale.class_index(1000.0), 3);
}
#[test]
fn threshold_defensively_sorts_and_dedups_unsorted_input() {
let scale = ThresholdScale::new(vec![30.0, 10.0, 20.0, 10.0]);
assert_eq!(scale.thresholds(), &[10.0, 20.0, 30.0]);
}
#[test]
fn threshold_empty_thresholds_is_one_trivial_class() {
let scale = ThresholdScale::new(Vec::new());
assert_eq!(scale.class_count(), 1);
assert_eq!(scale.class_index(42.0), 0);
assert_eq!(scale.class_label(0), "all");
}
#[test]
fn threshold_negative_and_mixed_sign_cut_points_classify_correctly() {
let scale = ThresholdScale::new(vec![-10.0, 0.0, 10.0]);
assert_eq!(scale.class_index(-20.0), 0);
assert_eq!(scale.class_index(-5.0), 1);
assert_eq!(scale.class_index(5.0), 2);
assert_eq!(scale.class_index(15.0), 3);
}
#[test]
fn threshold_labels_use_open_ended_bounds_at_the_extremes() {
let scale = ThresholdScale::new(vec![10.0, 20.0]);
assert!(scale.class_label(0).starts_with('<'));
assert!(scale.class_label(2).starts_with(">="));
assert!(scale.class_label(1).contains('–'));
}
#[test]
fn quantile_breaks_match_the_linear_interpolation_percentile_method() {
let scale = QuantileScale::new(&[1.0, 2.0, 3.0, 4.0], 4);
assert_eq!(scale.class_count(), 4);
let breaks = &scale.breaks;
assert!((breaks[0] - 1.75).abs() < 1e-9);
assert!((breaks[1] - 2.5).abs() < 1e-9);
assert!((breaks[2] - 3.25).abs() < 1e-9);
}
#[test]
fn quantile_empty_data_is_one_trivial_class_and_never_panics() {
let scale = QuantileScale::new(&[], 5);
assert_eq!(scale.class_count(), 1);
assert_eq!(scale.class_index(42.0), 0);
assert!(!scale.class_label(0).is_empty());
}
#[test]
fn quantile_single_sample_is_one_trivial_class() {
let scale = QuantileScale::new(&[7.0], 5);
assert_eq!(scale.class_count(), 1);
assert_eq!(scale.class_index(7.0), 0);
}
#[test]
fn quantile_all_equal_samples_never_panics_and_is_deterministic() {
let data = vec![5.0; 20];
let scale = QuantileScale::new(&data, 4);
let idx = scale.class_index(5.0);
for _ in 0..5 {
assert_eq!(scale.class_index(5.0), idx);
}
assert!(idx < scale.class_count());
}
#[test]
fn quantile_n_one_or_zero_is_one_trivial_class() {
let data: Vec<f64> = (0..50).map(|i| i as f64).collect();
let scale = QuantileScale::new(&data, 1);
assert_eq!(scale.class_count(), 1);
let scale0 = QuantileScale::new(&data, 0);
assert_eq!(scale0.class_count(), 1);
}
#[test]
fn quantile_non_finite_samples_are_dropped_before_computing_breaks() {
let data = vec![1.0, 2.0, f64::NAN, 3.0, 4.0, f64::INFINITY];
let scale = QuantileScale::new(&data, 4);
for b in &scale.breaks {
assert!(b.is_finite());
}
}
#[test]
fn quantile_roughly_equalizes_class_membership_counts() {
let data: Vec<f64> = (0..100).map(|i| i as f64).collect();
let scale = QuantileScale::new(&data, 4);
let mut counts = [0usize; 4];
for &v in &data {
counts[scale.class_index(v)] += 1;
}
for count in counts {
assert!((20..=30).contains(&count), "expected roughly equal class membership, got {counts:?}");
}
}
}