use serde::{Deserialize, Serialize};
use std::cmp::Ordering;
use std::collections::HashMap;
#[inline]
fn total_order_cmp(a: f64, b: f64) -> Ordering {
if a.is_nan() && b.is_nan() {
return Ordering::Equal;
}
if a.is_nan() {
return Ordering::Greater;
}
if b.is_nan() {
return Ordering::Less;
}
a.total_cmp(&b)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SharedBinningConfig {
pub num_bins: usize,
pub pooling_epsilon: f64,
pub rounding_precision: f64,
pub min_samples_per_bin: usize,
}
impl Default for SharedBinningConfig {
fn default() -> Self {
Self {
num_bins: 10,
pooling_epsilon: 1e-9,
rounding_precision: 1e-9,
min_samples_per_bin: 3,
}
}
}
#[derive(Debug, Clone)]
pub struct PrefixSums {
pub weights: Vec<f64>,
pub weighted_predictions: Vec<f64>,
pub weighted_labels: Vec<f64>,
pub count: usize,
}
impl PrefixSums {
pub fn new(predictions: &[f64], labels: &[f64], weights: &[f64]) -> Self {
assert_eq!(predictions.len(), labels.len());
assert_eq!(predictions.len(), weights.len());
let n = predictions.len();
let mut prefix_weights = Vec::with_capacity(n + 1);
let mut prefix_weighted_preds = Vec::with_capacity(n + 1);
let mut prefix_weighted_labels = Vec::with_capacity(n + 1);
prefix_weights.push(0.0);
prefix_weighted_preds.push(0.0);
prefix_weighted_labels.push(0.0);
for i in 0..n {
let w = weights[i];
let prev_w = prefix_weights[i];
let prev_wp = prefix_weighted_preds[i];
let prev_wl = prefix_weighted_labels[i];
prefix_weights.push(prev_w + w);
prefix_weighted_preds.push(prev_wp + w * predictions[i]);
prefix_weighted_labels.push(prev_wl + w * labels[i]);
}
Self {
weights: prefix_weights,
weighted_predictions: prefix_weighted_preds,
weighted_labels: prefix_weighted_labels,
count: n,
}
}
pub fn get_bin_stats(&self, start_idx: usize, end_idx: usize) -> BinStatistics {
assert!(start_idx <= end_idx);
assert!(end_idx <= self.count);
let weight = self.weights[end_idx] - self.weights[start_idx];
let weighted_pred = self.weighted_predictions[end_idx] - self.weighted_predictions[start_idx];
let weighted_label = self.weighted_labels[end_idx] - self.weighted_labels[start_idx];
BinStatistics {
weight,
weighted_prediction: weighted_pred,
weighted_label,
confidence: if weight > 0.0 { weighted_pred / weight } else { 0.0 },
accuracy: if weight > 0.0 { weighted_label / weight } else { 0.0 },
sample_count: end_idx - start_idx,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BinStatistics {
pub weight: f64,
pub weighted_prediction: f64,
pub weighted_label: f64,
pub confidence: f64,
pub accuracy: f64,
pub sample_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BinningResult {
pub bin_edges: Vec<f64>,
pub bin_stats: Vec<BinStatistics>,
pub bin_indices: Vec<Vec<usize>>,
pub merged_bin_count: usize,
pub config_hash: String,
}
pub struct SharedBinningCore {
config: SharedBinningConfig,
cached_edges: Option<Vec<f64>>,
index_buffer: Vec<usize>,
}
impl SharedBinningCore {
pub fn new(config: SharedBinningConfig) -> Self {
Self {
config,
cached_edges: None,
index_buffer: Vec::new(),
}
}
pub fn get_config_hash(&self) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
self.config.num_bins.hash(&mut hasher);
self.config.pooling_epsilon.to_bits().hash(&mut hasher);
self.config.rounding_precision.to_bits().hash(&mut hasher);
self.config.min_samples_per_bin.hash(&mut hasher);
format!("{:x}", hasher.finish())
}
#[inline]
fn round_to_precision(&self, value: f64) -> f64 {
let scale = 1.0 / self.config.rounding_precision;
(value * scale).round() / scale
}
fn pool_predictions(&self, predictions: &[f64], labels: &[f64], weights: &[f64]) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
let mut pooled_map: HashMap<u64, (f64, f64, f64)> = HashMap::new();
for i in 0..predictions.len() {
let rounded_pred = self.round_to_precision(predictions[i]);
let key = rounded_pred.to_bits();
let entry = pooled_map.entry(key).or_insert((rounded_pred, 0.0, 0.0));
entry.1 += labels[i] * weights[i]; entry.2 += weights[i]; }
let mut pooled: Vec<_> = pooled_map.into_iter()
.map(|(_, (pred, weighted_label, weight))| (pred, weighted_label / weight, weight))
.collect();
pooled.sort_by(|(a, _, _), (b, _, _)| total_order_cmp(*a, *b));
let (preds, labels, weights): (Vec<_>, Vec<_>, Vec<_>) = pooled.into_iter()
.fold((Vec::new(), Vec::new(), Vec::new()), |(mut p, mut l, mut w), (pred, label, weight)| {
p.push(pred);
l.push(label);
w.push(weight);
(p, l, w)
});
(preds, labels, weights)
}
fn compute_weighted_quantiles(&self, predictions: &[f64], weights: &[f64]) -> Vec<f64> {
let total_weight: f64 = weights.iter().sum();
let mut edges = Vec::with_capacity(self.config.num_bins + 1);
edges.push(predictions[0]);
let mut cumulative_weight = 0.0;
let mut current_idx = 0;
for bin_idx in 1..self.config.num_bins {
let target_weight = (bin_idx as f64) * total_weight / (self.config.num_bins as f64);
while current_idx < predictions.len() && cumulative_weight < target_weight {
cumulative_weight += weights[current_idx];
current_idx += 1;
}
if current_idx > 0 && current_idx <= predictions.len() {
edges.push(predictions[current_idx - 1]);
}
}
edges.push(predictions[predictions.len() - 1]);
edges.dedup();
edges
}
fn assign_to_bins(&self, predictions: &[f64], edges: &[f64]) -> Vec<Vec<usize>> {
let mut bin_indices = vec![Vec::new(); edges.len() - 1];
for (idx, &pred) in predictions.iter().enumerate() {
let bin_idx = match edges.binary_search_by(|&edge| total_order_cmp(edge, pred)) {
Ok(exact_idx) => {
if exact_idx < edges.len() - 1 {
exact_idx
} else {
exact_idx - 1 }
}
Err(insert_idx) => {
if insert_idx > 0 {
insert_idx - 1 } else {
0 }
}
};
let final_bin_idx = bin_idx.min(bin_indices.len() - 1);
bin_indices[final_bin_idx].push(idx);
}
bin_indices
}
fn merge_small_bins(&self, mut bin_indices: Vec<Vec<usize>>, mut edges: Vec<f64>) -> (Vec<Vec<usize>>, Vec<f64>, usize) {
let mut merged_count = 0;
let min_samples = self.config.min_samples_per_bin;
let mut i = 0;
while i < bin_indices.len() {
if bin_indices[i].len() < min_samples && bin_indices.len() > 1 {
if i + 1 < bin_indices.len() {
let next_bin = bin_indices[i + 1].clone();
bin_indices[i].extend(next_bin);
bin_indices.remove(i + 1);
edges.remove(i + 1); merged_count += 1;
} else if i > 0 {
let current_bin = bin_indices[i].clone();
bin_indices[i - 1].extend(current_bin);
bin_indices.remove(i);
edges.remove(i);
merged_count += 1;
i = i.saturating_sub(1); }
} else {
i += 1;
}
}
(bin_indices, edges, merged_count)
}
pub fn bin_samples(&mut self, predictions: &[f64], labels: &[f64], weights: &[f64]) -> BinningResult {
assert_eq!(predictions.len(), labels.len());
assert_eq!(predictions.len(), weights.len());
let (pooled_preds, pooled_labels, pooled_weights) = self.pool_predictions(predictions, labels, weights);
let edges = if let Some(ref cached) = self.cached_edges {
cached.clone()
} else {
let edges = self.compute_weighted_quantiles(&pooled_preds, &pooled_weights);
self.cached_edges = Some(edges.clone());
edges
};
let bin_indices = self.assign_to_bins(&pooled_preds, &edges);
let (final_bin_indices, final_edges, merged_count) = self.merge_small_bins(bin_indices, edges);
let prefix_sums = PrefixSums::new(&pooled_preds, &pooled_labels, &pooled_weights);
let mut bin_stats = Vec::with_capacity(final_bin_indices.len());
for indices in &final_bin_indices {
if indices.is_empty() {
bin_stats.push(BinStatistics {
weight: 0.0,
weighted_prediction: 0.0,
weighted_label: 0.0,
confidence: 0.0,
accuracy: 0.0,
sample_count: 0,
});
} else {
let start_idx = *indices.iter().min().unwrap();
let end_idx = *indices.iter().max().unwrap() + 1;
bin_stats.push(prefix_sums.get_bin_stats(start_idx, end_idx));
}
}
BinningResult {
bin_edges: final_edges,
bin_stats,
bin_indices: final_bin_indices,
merged_bin_count: merged_count,
config_hash: self.get_config_hash(),
}
}
pub fn invalidate_cache(&mut self) {
self.cached_edges = None;
}
}
trait UnzipThree<A, B, C> {
fn unzip3(self) -> (Vec<A>, Vec<B>, Vec<C>);
}
impl<A, B, C> UnzipThree<A, B, C> for Vec<(A, B, C)> {
fn unzip3(self) -> (Vec<A>, Vec<B>, Vec<C>) {
let mut vec_a = Vec::with_capacity(self.len());
let mut vec_b = Vec::with_capacity(self.len());
let mut vec_c = Vec::with_capacity(self.len());
for (a, b, c) in self {
vec_a.push(a);
vec_b.push(b);
vec_c.push(c);
}
(vec_a, vec_b, vec_c)
}
}
#[cfg(target_arch = "wasm32")]
pub mod wasm_exports {
use super::*;
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub struct WasmBinningCore {
inner: SharedBinningCore,
}
#[wasm_bindgen]
impl WasmBinningCore {
#[wasm_bindgen(constructor)]
pub fn new(num_bins: usize, pooling_epsilon: f64, rounding_precision: f64, min_samples_per_bin: usize) -> Self {
let config = SharedBinningConfig {
num_bins,
pooling_epsilon,
rounding_precision,
min_samples_per_bin,
};
Self {
inner: SharedBinningCore::new(config),
}
}
#[wasm_bindgen]
pub fn get_config_hash(&self) -> String {
self.inner.get_config_hash()
}
#[wasm_bindgen]
pub fn bin_samples(&mut self, predictions: &[f64], labels: &[f64], weights: &[f64]) -> String {
let result = self.inner.bin_samples(predictions, labels, weights);
serde_json::to_string(&result).unwrap_or_else(|_| "{}".to_string())
}
#[wasm_bindgen]
pub fn invalidate_cache(&mut self) {
self.inner.invalidate_cache();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_total_order_determinism() {
let values = vec![0.0, -0.0, 1.0, -1.0, f64::INFINITY, f64::NEG_INFINITY, f64::NAN];
let mut sorted1 = values.clone();
let mut sorted2 = values.clone();
sorted1.sort_by(|a, b| total_order_cmp(*a, *b));
sorted2.sort_by(|a, b| total_order_cmp(*a, *b));
assert_eq!(sorted1.len(), sorted2.len());
for (a, b) in sorted1.iter().zip(sorted2.iter()) {
if a.is_nan() && b.is_nan() {
continue; }
assert_eq!(a.to_bits(), b.to_bits(), "Deterministic sort failed: {} vs {}", a, b);
}
}
#[test]
fn test_prefix_sums() {
let predictions = vec![0.1, 0.3, 0.7, 0.9];
let labels = vec![0.0, 0.0, 1.0, 1.0];
let weights = vec![1.0, 2.0, 1.0, 3.0];
let prefix_sums = PrefixSums::new(&predictions, &labels, &weights);
let full_stats = prefix_sums.get_bin_stats(0, 4);
assert_eq!(full_stats.weight, 7.0);
assert_eq!(full_stats.weighted_prediction, 0.1 + 0.6 + 0.7 + 2.7); assert_eq!(full_stats.weighted_label, 0.0 + 0.0 + 1.0 + 3.0);
let partial_stats = prefix_sums.get_bin_stats(1, 3);
assert_eq!(partial_stats.weight, 3.0); assert_eq!(partial_stats.sample_count, 2);
}
#[test]
fn test_shared_binning_determinism() {
let config = SharedBinningConfig::default();
let mut core = SharedBinningCore::new(config);
let predictions = vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9];
let labels = vec![0.0, 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0];
let weights = vec![1.0; 9];
let result1 = core.bin_samples(&predictions, &labels, &weights);
let result2 = core.bin_samples(&predictions, &labels, &weights);
assert_eq!(result1.config_hash, result2.config_hash);
assert_eq!(result1.bin_edges, result2.bin_edges);
assert_eq!(result1.merged_bin_count, result2.merged_bin_count);
let hash1 = core.get_config_hash();
let hash2 = core.get_config_hash();
assert_eq!(hash1, hash2);
}
}