Packages
ferricstore
0.1.0
0.11.14
0.11.12
0.11.11
0.11.10
0.11.9
0.11.8
0.11.7
0.11.6
0.11.5
0.11.4
0.11.3
0.11.2
0.11.1
0.11.0
0.10.3
0.10.2
0.10.1
0.10.0
0.9.1
0.9.0
0.8.0
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.0
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.3
0.4.2
0.4.1
0.4.0
0.3.7
0.3.6
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.2.0
0.1.0
FerricFlow durable workflows and queues with native-protocol storage, Raft durability, and Bitcask persistence.
Current section
Files
Jump to
Current section
Files
native/ferricstore_bitcask/src/tdigest.rs
//! T-Digest data structure for accurate on-line accumulation of rank-based
//! statistics such as quantiles, trimmed means, and CDF values.
//!
//! Implements the merging digest algorithm from Dunning's paper using the
//! k1 scale function: `k(q) = (delta / (2 * pi)) * arcsin(2q - 1)`.
//!
//! Centroids near the tails (q near 0 or 1) are forced to be small, while
//! centroids near the median can absorb many observations. This gives high
//! accuracy at the tails (P99, P99.9) with bounded memory.
use std::sync::Mutex;
use rustler::{Encoder, Env, NifResult, ResourceArc, Term};
// ---------------------------------------------------------------------------
// Core data structures
// ---------------------------------------------------------------------------
#[derive(Clone)]
struct Centroid {
mean: f64,
weight: f64,
}
pub struct TDigest {
compression: f64,
centroids: Vec<Centroid>,
count: u64,
min: f64,
max: f64,
buffer: Vec<f64>,
buffer_max: usize,
total_compressions: u64,
}
/// Resource wrapper for BEAM GC tracking.
pub struct TDigestResource {
pub digest: Mutex<TDigest>,
}
impl TDigest {
fn new(compression: f64) -> Self {
let buffer_max = (compression * 3.0).ceil() as usize;
Self {
compression,
centroids: Vec::new(),
count: 0,
min: f64::INFINITY,
max: f64::NEG_INFINITY,
buffer: Vec::with_capacity(buffer_max),
buffer_max,
total_compressions: 0,
}
}
fn is_empty(&self) -> bool {
self.count == 0
}
fn add(&mut self, value: f64) {
if value < self.min {
self.min = value;
}
if value > self.max {
self.max = value;
}
self.buffer.push(value);
self.count += 1;
if self.buffer.len() >= self.buffer_max {
self.compress();
}
}
fn add_many(&mut self, values: &[f64]) {
for &v in values {
self.add(v);
}
}
fn ensure_compressed(&mut self) {
if !self.buffer.is_empty() {
self.compress();
}
}
fn compress(&mut self) {
if self.buffer.is_empty() {
return;
}
// Convert buffer samples to weight-1 centroids and combine with existing
let mut all: Vec<Centroid> = self.centroids.clone();
for &v in &self.buffer {
all.push(Centroid {
mean: v,
weight: 1.0,
});
}
// Sort by mean
all.sort_by(|a, b| {
a.mean
.partial_cmp(&b.mean)
.unwrap_or(std::cmp::Ordering::Equal)
});
let total_weight: f64 = all.iter().map(|c| c.weight).sum();
// Alternate merge direction to avoid bias
if self.total_compressions % 2 == 1 {
all.reverse();
}
let merged = Self::merge_centroids(&all, total_weight, self.compression);
// Ensure result is always sorted ascending by mean
self.centroids = if self.total_compressions % 2 == 1 {
let mut v = merged;
v.reverse();
v
} else {
merged
};
self.buffer.clear();
self.total_compressions += 1;
}
fn merge_centroids(sorted: &[Centroid], total_weight: f64, compression: f64) -> Vec<Centroid> {
if sorted.is_empty() || total_weight <= 0.0 {
return Vec::new();
}
let mut result: Vec<Centroid> = Vec::new();
let mut current = sorted[0].clone();
let mut weight_so_far = current.weight / 2.0;
for centroid in sorted.iter().skip(1) {
let proposed_weight = current.weight + centroid.weight;
let q = (weight_so_far + proposed_weight / 2.0) / total_weight;
let max_w = Self::max_weight(q, total_weight, compression);
if proposed_weight <= max_w {
// Merge into current centroid
let new_mean = (current.mean * current.weight + centroid.mean * centroid.weight)
/ proposed_weight;
current = Centroid {
mean: new_mean,
weight: proposed_weight,
};
} else {
// Start a new centroid
weight_so_far += current.weight;
result.push(current);
current = centroid.clone();
weight_so_far += current.weight / 2.0;
}
}
result.push(current);
result
}
/// Maximum weight a centroid at quantile position q can have.
/// Uses the k1 scale function derivative.
fn max_weight(q: f64, total_weight: f64, compression: f64) -> f64 {
let q = q.clamp(1.0e-10, 1.0 - 1.0e-10);
4.0 * total_weight * q * (1.0 - q) / compression
}
// -----------------------------------------------------------------------
// Query functions
// -----------------------------------------------------------------------
fn quantile(&mut self, q: f64) -> f64 {
self.ensure_compressed();
if self.is_empty() {
return f64::NAN;
}
#[allow(clippy::float_cmp)]
if q == 0.0 {
return self.min;
}
#[allow(clippy::float_cmp)]
if q == 1.0 {
return self.max;
}
if self.centroids.is_empty() {
if self.min.is_finite() && self.max.is_finite() {
return self.min + q * (self.max - self.min);
}
return f64::NAN;
}
let total_weight = self.count as f64;
let target = q * total_weight;
let mut cum_weight = 0.0;
let mut result = None;
for (i, c) in self.centroids.iter().enumerate() {
let new_cum = cum_weight + c.weight;
if new_cum >= target {
if i == 0 {
// First centroid: interpolate between min and centroid mean
if target < c.weight / 2.0 {
let frac = target / (c.weight / 2.0);
let val = self.min + frac * (c.mean - self.min);
result = Some(val);
} else {
result = Some(c.mean);
}
} else {
result = Some(c.mean);
}
break;
}
cum_weight = new_cum;
}
match result {
Some(val) => val.clamp(self.min, self.max),
None => self.max,
}
}
fn cdf(&mut self, value: f64) -> f64 {
self.ensure_compressed();
if self.is_empty() {
return f64::NAN;
}
if value <= self.min {
return 0.0;
}
if value >= self.max {
return 1.0;
}
if self.centroids.is_empty() {
#[allow(clippy::float_cmp)]
if self.max == self.min {
return 0.5;
}
return (value - self.min) / (self.max - self.min);
}
let total_weight = self.count as f64;
let mut cum_weight = 0.0;
let mut prev: Option<&Centroid> = None;
for c in &self.centroids {
if value < c.mean {
if prev.is_none() {
// Before the first centroid
let frac = (value - self.min) / (c.mean - self.min);
let partial = frac * c.weight / 2.0;
return (partial / total_weight).clamp(0.0, 1.0);
}
let prev_c = prev.unwrap();
let frac = (value - prev_c.mean) / (c.mean - prev_c.mean);
let partial = cum_weight - prev_c.weight / 2.0
+ frac * (prev_c.weight / 2.0 + c.weight / 2.0);
return (partial / total_weight).clamp(0.0, 1.0);
}
if (value - c.mean).abs() < f64::EPSILON {
let partial = cum_weight + c.weight / 2.0;
return (partial / total_weight).clamp(0.0, 1.0);
}
cum_weight += c.weight;
prev = Some(c);
}
// Value is beyond the last centroid but within max
if let Some(last) = self.centroids.last() {
if (self.max - last.mean).abs() < f64::EPSILON {
return 1.0;
}
let frac = (value - last.mean) / (self.max - last.mean);
let partial = cum_weight - last.weight / 2.0 + frac * (last.weight / 2.0);
return (partial / total_weight).min(1.0);
}
1.0
}
fn rank(&mut self, value: f64) -> i64 {
self.ensure_compressed();
if self.is_empty() {
return -2;
}
let fvalue = value;
if fvalue < self.min {
return -1;
}
if fvalue >= self.max {
return self.count as i64;
}
let cdf_val = self.cdf(fvalue);
(cdf_val * self.count as f64).floor() as i64
}
fn rev_rank(&mut self, value: f64) -> i64 {
self.ensure_compressed();
if self.is_empty() {
return -2;
}
let r = self.rank(value);
if r == -1 {
return self.count as i64;
}
if r >= self.count as i64 {
return -1;
}
self.count as i64 - r - 1
}
fn by_rank(&mut self, r: i64) -> ByRankResult {
self.ensure_compressed();
if self.is_empty() {
return ByRankResult::NaN;
}
if r < 0 {
return ByRankResult::NegInf;
}
if r >= self.count as i64 {
return ByRankResult::Inf;
}
let q = (r as f64 + 0.5) / self.count as f64;
ByRankResult::Value(self.quantile(q))
}
fn by_rev_rank(&mut self, r: i64) -> ByRankResult {
self.ensure_compressed();
if self.is_empty() {
return ByRankResult::NaN;
}
if r < 0 {
return ByRankResult::Inf;
}
if r >= self.count as i64 {
return ByRankResult::NegInf;
}
let q = 1.0 - (r as f64 + 0.5) / self.count as f64;
ByRankResult::Value(self.quantile(q))
}
fn trimmed_mean(&mut self, lo: f64, hi: f64) -> f64 {
self.ensure_compressed();
if self.is_empty() {
return f64::NAN;
}
let total_weight = self.count as f64;
let lo_weight = lo * total_weight;
let hi_weight = hi * total_weight;
let mut sum = 0.0;
let mut weight_sum = 0.0;
let mut cum = 0.0;
for c in &self.centroids {
let centroid_lo = cum;
let centroid_hi = cum + c.weight;
let overlap_lo = centroid_lo.max(lo_weight);
let overlap_hi = centroid_hi.min(hi_weight);
if overlap_hi > overlap_lo {
let overlap_weight = overlap_hi - overlap_lo;
sum += c.mean * overlap_weight;
weight_sum += overlap_weight;
}
cum += c.weight;
}
if weight_sum > 0.0 {
sum / weight_sum
} else {
f64::NAN
}
}
fn min_val(&self) -> f64 {
if self.is_empty() {
f64::NAN
} else {
self.min
}
}
fn max_val(&self) -> f64 {
if self.is_empty() {
f64::NAN
} else {
self.max
}
}
fn info(&self) -> TDigestInfo {
let merged_weight: f64 = self.centroids.iter().map(|c| c.weight).sum();
let centroid_mem = self.centroids.len() * 16; // 2 x f64
let buffer_mem = self.buffer.len() * 8; // 1 x f64
let struct_overhead = 200;
TDigestInfo {
compression: self.compression as u64,
capacity: self.buffer_max as u64,
merged_nodes: self.centroids.len() as u64,
unmerged_nodes: self.buffer.len() as u64,
merged_weight,
unmerged_weight: self.buffer.len() as f64,
total_compressions: self.total_compressions,
memory_usage: (centroid_mem + buffer_mem + struct_overhead) as u64,
}
}
fn reset(&mut self) {
let compression = self.compression;
let buffer_max = self.buffer_max;
self.centroids.clear();
self.count = 0;
self.min = f64::INFINITY;
self.max = f64::NEG_INFINITY;
self.buffer.clear();
self.buffer_max = buffer_max;
self.compression = compression;
self.total_compressions = 0;
}
// -----------------------------------------------------------------------
// Serialization: flat f64 array for Bitcask byte-array storage
// -----------------------------------------------------------------------
/// Serialize to a byte array: [compression, count, min, max, total_compressions,
/// n_centroids, mean0, weight0, mean1, weight1, ..., n_buffer, buf0, buf1, ...]
fn serialize(&self) -> Vec<u8> {
let n_centroids = self.centroids.len();
let n_buffer = self.buffer.len();
// 5 header f64s + 1 n_centroids + 2*n_centroids + 1 n_buffer + n_buffer
let total_f64s = 5 + 1 + 2 * n_centroids + 1 + n_buffer;
let mut bytes = Vec::with_capacity(total_f64s * 8);
bytes.extend_from_slice(&self.compression.to_le_bytes());
bytes.extend_from_slice(&(self.count as f64).to_le_bytes());
bytes.extend_from_slice(&self.min.to_le_bytes());
bytes.extend_from_slice(&self.max.to_le_bytes());
bytes.extend_from_slice(&(self.total_compressions as f64).to_le_bytes());
bytes.extend_from_slice(&(n_centroids as f64).to_le_bytes());
for c in &self.centroids {
bytes.extend_from_slice(&c.mean.to_le_bytes());
bytes.extend_from_slice(&c.weight.to_le_bytes());
}
bytes.extend_from_slice(&(n_buffer as f64).to_le_bytes());
for &v in &self.buffer {
bytes.extend_from_slice(&v.to_le_bytes());
}
bytes
}
/// Deserialize from a byte array produced by `serialize`.
fn deserialize(bytes: &[u8]) -> Option<Self> {
if bytes.len() < 6 * 8 {
return None;
}
let mut pos = 0;
let read_f64 = |data: &[u8], offset: &mut usize| -> Option<f64> {
if *offset + 8 > data.len() {
return None;
}
let arr: [u8; 8] = data[*offset..*offset + 8].try_into().ok()?;
*offset += 8;
Some(f64::from_le_bytes(arr))
};
let compression = read_f64(bytes, &mut pos)?;
let count = read_f64(bytes, &mut pos)? as u64;
let min = read_f64(bytes, &mut pos)?;
let max = read_f64(bytes, &mut pos)?;
let total_compressions = read_f64(bytes, &mut pos)? as u64;
let n_centroids = read_f64(bytes, &mut pos)? as usize;
let mut centroids = Vec::with_capacity(n_centroids);
for _ in 0..n_centroids {
let mean = read_f64(bytes, &mut pos)?;
let weight = read_f64(bytes, &mut pos)?;
centroids.push(Centroid { mean, weight });
}
let n_buffer = read_f64(bytes, &mut pos)? as usize;
let mut buffer = Vec::with_capacity(n_buffer);
for _ in 0..n_buffer {
let v = read_f64(bytes, &mut pos)?;
buffer.push(v);
}
let buffer_max = (compression * 3.0).ceil() as usize;
Some(Self {
compression,
centroids,
count,
min,
max,
buffer,
buffer_max,
total_compressions,
})
}
}
enum ByRankResult {
Value(f64),
NaN,
Inf,
NegInf,
}
struct TDigestInfo {
compression: u64,
capacity: u64,
merged_nodes: u64,
unmerged_nodes: u64,
merged_weight: f64,
unmerged_weight: f64,
total_compressions: u64,
memory_usage: u64,
}
// ---------------------------------------------------------------------------
// Merge helper (operates on raw TDigest values, not resources)
// ---------------------------------------------------------------------------
fn merge_digests(digests: &mut [&mut TDigest], compression: f64) -> TDigest {
// Ensure all compressed
for d in digests.iter_mut() {
d.ensure_compressed();
}
let mut all_centroids: Vec<Centroid> = Vec::new();
let mut total_weight: u64 = 0;
let mut global_min = f64::INFINITY;
let mut global_max = f64::NEG_INFINITY;
let mut total_comps: u64 = 0;
for d in digests.iter() {
all_centroids.extend(d.centroids.iter().cloned());
total_weight += d.count;
if d.count > 0 {
if d.min < global_min {
global_min = d.min;
}
if d.max > global_max {
global_max = d.max;
}
}
total_comps += d.total_compressions;
}
all_centroids.sort_by(|a, b| {
a.mean
.partial_cmp(&b.mean)
.unwrap_or(std::cmp::Ordering::Equal)
});
let merged = TDigest::merge_centroids(&all_centroids, total_weight as f64, compression);
let buffer_max = (compression * 3.0).ceil() as usize;
TDigest {
compression,
centroids: merged,
count: total_weight,
min: global_min,
max: global_max,
buffer: Vec::new(),
buffer_max,
total_compressions: total_comps + 1,
}
}
// ---------------------------------------------------------------------------
// NIF functions
// ---------------------------------------------------------------------------
mod atoms {
rustler::atoms! {
ok,
nan,
inf,
neg_inf = "-inf",
compression,
capacity,
merged_nodes,
unmerged_nodes,
merged_weight,
unmerged_weight,
total_compressions,
memory_usage,
}
}
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
fn tdigest_create(env: Env, comp: f64) -> NifResult<Term> {
let digest = TDigest::new(comp);
let resource = ResourceArc::new(TDigestResource {
digest: Mutex::new(digest),
});
Ok(resource.encode(env))
}
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
fn tdigest_add(
env: Env,
resource: ResourceArc<TDigestResource>,
values: Vec<f64>,
) -> NifResult<Term> {
let mut digest = resource.digest.lock().map_err(|_| rustler::Error::BadArg)?;
digest.add_many(&values);
Ok(atoms::ok().encode(env))
}
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
fn tdigest_quantile(
env: Env,
resource: ResourceArc<TDigestResource>,
quantiles: Vec<f64>,
) -> NifResult<Term> {
let mut digest = resource.digest.lock().map_err(|_| rustler::Error::BadArg)?;
let results: Vec<Term> = quantiles
.iter()
.map(|&q| {
let val = digest.quantile(q);
if val.is_nan() {
atoms::nan().encode(env)
} else {
val.encode(env)
}
})
.collect();
Ok(results.encode(env))
}
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
fn tdigest_cdf(
env: Env,
resource: ResourceArc<TDigestResource>,
values: Vec<f64>,
) -> NifResult<Term> {
let mut digest = resource.digest.lock().map_err(|_| rustler::Error::BadArg)?;
let results: Vec<Term> = values
.iter()
.map(|&v| {
let val = digest.cdf(v);
if val.is_nan() {
atoms::nan().encode(env)
} else {
val.encode(env)
}
})
.collect();
Ok(results.encode(env))
}
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
fn tdigest_trimmed_mean(
env: Env,
resource: ResourceArc<TDigestResource>,
lo: f64,
hi: f64,
) -> NifResult<Term> {
let mut digest = resource.digest.lock().map_err(|_| rustler::Error::BadArg)?;
let val = digest.trimmed_mean(lo, hi);
if val.is_nan() {
Ok(atoms::nan().encode(env))
} else {
Ok(val.encode(env))
}
}
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
fn tdigest_min(env: Env, resource: ResourceArc<TDigestResource>) -> NifResult<Term> {
let digest = resource.digest.lock().map_err(|_| rustler::Error::BadArg)?;
let val = digest.min_val();
if val.is_nan() {
Ok(atoms::nan().encode(env))
} else {
Ok(val.encode(env))
}
}
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
fn tdigest_max(env: Env, resource: ResourceArc<TDigestResource>) -> NifResult<Term> {
let digest = resource.digest.lock().map_err(|_| rustler::Error::BadArg)?;
let val = digest.max_val();
if val.is_nan() {
Ok(atoms::nan().encode(env))
} else {
Ok(val.encode(env))
}
}
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
fn tdigest_info(env: Env, resource: ResourceArc<TDigestResource>) -> NifResult<Term> {
let digest = resource.digest.lock().map_err(|_| rustler::Error::BadArg)?;
let info = digest.info();
// Return as a flat list matching the existing Elixir format expectations
let result = vec![
atoms::compression().encode(env),
info.compression.encode(env),
atoms::capacity().encode(env),
info.capacity.encode(env),
atoms::merged_nodes().encode(env),
info.merged_nodes.encode(env),
atoms::unmerged_nodes().encode(env),
info.unmerged_nodes.encode(env),
atoms::merged_weight().encode(env),
info.merged_weight.encode(env),
atoms::unmerged_weight().encode(env),
info.unmerged_weight.encode(env),
atoms::total_compressions().encode(env),
info.total_compressions.encode(env),
atoms::memory_usage().encode(env),
info.memory_usage.encode(env),
];
Ok(result.encode(env))
}
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
fn tdigest_rank(
env: Env,
resource: ResourceArc<TDigestResource>,
values: Vec<f64>,
) -> NifResult<Term> {
let mut digest = resource.digest.lock().map_err(|_| rustler::Error::BadArg)?;
let results: Vec<i64> = values.iter().map(|&v| digest.rank(v)).collect();
Ok(results.encode(env))
}
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
fn tdigest_revrank(
env: Env,
resource: ResourceArc<TDigestResource>,
values: Vec<f64>,
) -> NifResult<Term> {
let mut digest = resource.digest.lock().map_err(|_| rustler::Error::BadArg)?;
let results: Vec<i64> = values.iter().map(|&v| digest.rev_rank(v)).collect();
Ok(results.encode(env))
}
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
fn tdigest_byrank(
env: Env,
resource: ResourceArc<TDigestResource>,
ranks: Vec<i64>,
) -> NifResult<Term> {
let mut digest = resource.digest.lock().map_err(|_| rustler::Error::BadArg)?;
let results: Vec<Term> = ranks
.iter()
.map(|&r| match digest.by_rank(r) {
ByRankResult::Value(v) => v.encode(env),
ByRankResult::NaN => atoms::nan().encode(env),
ByRankResult::Inf => atoms::inf().encode(env),
ByRankResult::NegInf => atoms::neg_inf().encode(env),
})
.collect();
Ok(results.encode(env))
}
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
fn tdigest_byrevrank(
env: Env,
resource: ResourceArc<TDigestResource>,
ranks: Vec<i64>,
) -> NifResult<Term> {
let mut digest = resource.digest.lock().map_err(|_| rustler::Error::BadArg)?;
let results: Vec<Term> = ranks
.iter()
.map(|&r| match digest.by_rev_rank(r) {
ByRankResult::Value(v) => v.encode(env),
ByRankResult::NaN => atoms::nan().encode(env),
ByRankResult::Inf => atoms::inf().encode(env),
ByRankResult::NegInf => atoms::neg_inf().encode(env),
})
.collect();
Ok(results.encode(env))
}
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
fn tdigest_merge(
env: Env,
resources: Vec<ResourceArc<TDigestResource>>,
comp: f64,
) -> NifResult<Term> {
// Lock all resources
let mut guards: Vec<std::sync::MutexGuard<TDigest>> = Vec::with_capacity(resources.len());
for r in &resources {
let guard = r.digest.lock().map_err(|_| rustler::Error::BadArg)?;
guards.push(guard);
}
let mut ptrs: Vec<&mut TDigest> = guards.iter_mut().map(|g| &mut **g).collect();
let merged = merge_digests(&mut ptrs, comp);
let resource = ResourceArc::new(TDigestResource {
digest: Mutex::new(merged),
});
Ok(resource.encode(env))
}
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
fn tdigest_reset(env: Env, resource: ResourceArc<TDigestResource>) -> NifResult<Term> {
let mut digest = resource.digest.lock().map_err(|_| rustler::Error::BadArg)?;
digest.reset();
Ok(atoms::ok().encode(env))
}
/// Serialize the digest to a byte array for Bitcask storage.
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
fn tdigest_serialize(env: Env, resource: ResourceArc<TDigestResource>) -> NifResult<Term> {
let digest = resource.digest.lock().map_err(|_| rustler::Error::BadArg)?;
let bytes = digest.serialize();
let mut bin = rustler::OwnedBinary::new(bytes.len()).ok_or(rustler::Error::BadArg)?;
bin.as_mut_slice().copy_from_slice(&bytes);
Ok(rustler::Binary::from_owned(bin, env).encode(env))
}
/// Deserialize a byte array back into a TDigest resource.
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
fn tdigest_deserialize<'a>(env: Env<'a>, data: rustler::Binary<'a>) -> NifResult<Term<'a>> {
match TDigest::deserialize(data.as_slice()) {
Some(digest) => {
let resource = ResourceArc::new(TDigestResource {
digest: Mutex::new(digest),
});
Ok((crate::atoms::ok(), resource).encode(env))
}
None => Ok((crate::atoms::error(), "invalid tdigest data").encode(env)),
}
}
// ---------------------------------------------------------------------------
// Resource registration (called from lib.rs load)
// ---------------------------------------------------------------------------
#[allow(non_local_definitions)]
pub fn register_resource(env: Env) {
let _ = rustler::resource!(TDigestResource, env);
}
/// Stub for mmap-backed tdigest resource registration (not yet implemented).
pub fn register_mmap_resource(_env: Env) {
// No-op: file-backed TDigest resources are not yet implemented.
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
#[allow(clippy::float_cmp)]
mod tests {
use super::*;
#[test]
fn empty_digest() {
let mut td = TDigest::new(100.0);
assert!(td.is_empty());
assert!(td.quantile(0.5).is_nan());
assert!(td.cdf(50.0).is_nan());
assert_eq!(td.rank(50.0), -2);
}
#[test]
fn single_value() {
let mut td = TDigest::new(100.0);
td.add(42.0);
assert!(!td.is_empty());
assert_eq!(td.count, 1);
assert_eq!(td.min, 42.0);
assert_eq!(td.max, 42.0);
let q50 = td.quantile(0.5);
assert!((q50 - 42.0).abs() < 0.1);
}
#[test]
fn uniform_1000() {
let mut td = TDigest::new(100.0);
for i in 1..=1000 {
td.add(i as f64);
}
let p50 = td.quantile(0.5);
assert!((p50 - 500.0).abs() < 500.0 * 0.05);
let p99 = td.quantile(0.99);
assert!((p99 - 990.0).abs() < 990.0 * 0.02);
}
#[test]
fn cdf_basic() {
let mut td = TDigest::new(100.0);
for i in 1..=1000 {
td.add(i as f64);
}
let cdf_500 = td.cdf(500.0);
assert!((cdf_500 - 0.5).abs() < 0.05);
}
#[test]
fn serialize_roundtrip() {
let mut td = TDigest::new(100.0);
for i in 1..=1000 {
td.add(i as f64);
}
td.ensure_compressed();
let bytes = td.serialize();
let td2 = TDigest::deserialize(&bytes).unwrap();
assert_eq!(td.compression, td2.compression);
assert_eq!(td.count, td2.count);
assert_eq!(td.min, td2.min);
assert_eq!(td.max, td2.max);
assert_eq!(td.centroids.len(), td2.centroids.len());
let q50_1 = td.quantile(0.5);
let mut td2 = td2;
let q50_2 = td2.quantile(0.5);
assert!((q50_1 - q50_2).abs() < 0.01);
}
#[test]
fn merge_two_digests() {
let mut d1 = TDigest::new(100.0);
let mut d2 = TDigest::new(100.0);
for i in 1..=500 {
d1.add(i as f64);
}
for i in 501..=1000 {
d2.add(i as f64);
}
let mut merged = merge_digests(&mut [&mut d1, &mut d2], 100.0);
assert_eq!(merged.count, 1000);
let p50 = merged.quantile(0.5);
assert!((p50 - 500.0).abs() < 500.0 * 0.1);
}
#[test]
fn reset_preserves_compression() {
let mut td = TDigest::new(250.0);
td.add(1.0);
td.add(2.0);
td.reset();
assert!(td.is_empty());
assert_eq!(td.compression, 250.0);
}
#[test]
fn trimmed_mean_full_range() {
let mut td = TDigest::new(100.0);
for i in 1..=100 {
td.add(i as f64);
}
let tm = td.trimmed_mean(0.0, 1.0);
// True mean of 1..100 = 50.5
assert!((tm - 50.5).abs() < 50.5 * 0.05);
}
#[test]
fn by_rank_edges() {
let mut td = TDigest::new(100.0);
td.add(10.0);
td.add(20.0);
td.add(30.0);
match td.by_rank(-1) {
ByRankResult::NegInf => {}
_ => panic!("expected NegInf"),
}
match td.by_rank(3) {
ByRankResult::Inf => {}
_ => panic!("expected Inf"),
}
}
// -----------------------------------------------------------------------
// Edge-case tests
// -----------------------------------------------------------------------
#[test]
fn add_three_values_median_approx_2() {
let mut td = TDigest::new(100.0);
td.add(1.0);
td.add(2.0);
td.add(3.0);
let p50 = td.quantile(0.5);
assert!(
(p50 - 2.0).abs() < 1.0,
"median of [1,2,3] should be ~2, got {p50}"
);
}
#[test]
fn p50_within_5_percent_of_500() {
let mut td = TDigest::new(100.0);
for i in 1..=1000 {
td.add(i as f64);
}
let p50 = td.quantile(0.5);
assert!(
(p50 - 500.0).abs() < 500.0 * 0.05,
"p50={p50}, expected ~500 +/- 5%"
);
}
#[test]
fn p99_within_1_percent_of_990() {
let mut td = TDigest::new(200.0);
for i in 1..=1000 {
td.add(i as f64);
}
let p99 = td.quantile(0.99);
assert!(
(p99 - 990.0).abs() < 990.0 * 0.02,
"p99={p99}, expected ~990 +/- 2%"
);
}
#[test]
fn p0_returns_min() {
let mut td = TDigest::new(100.0);
td.add(5.0);
td.add(10.0);
td.add(15.0);
let p0 = td.quantile(0.0);
assert_eq!(p0, 5.0);
}
#[test]
fn p1_returns_max() {
let mut td = TDigest::new(100.0);
td.add(5.0);
td.add(10.0);
td.add(15.0);
let p1 = td.quantile(1.0);
assert_eq!(p1, 15.0);
}
#[test]
fn cdf_of_min_approx_zero() {
let mut td = TDigest::new(100.0);
for i in 1..=100 {
td.add(i as f64);
}
let cdf_min = td.cdf(1.0);
assert!(cdf_min < 0.05, "CDF of min should be ~0, got {cdf_min}");
}
#[test]
fn cdf_of_max_approx_one() {
let mut td = TDigest::new(100.0);
for i in 1..=100 {
td.add(i as f64);
}
let cdf_max = td.cdf(100.0);
assert!(cdf_max > 0.95, "CDF of max should be ~1.0, got {cdf_max}");
}
#[test]
fn merge_combined_quantiles_correct() {
let mut d1 = TDigest::new(100.0);
let mut d2 = TDigest::new(100.0);
for i in 1..=500 {
d1.add(i as f64);
}
for i in 501..=1000 {
d2.add(i as f64);
}
let mut merged = merge_digests(&mut [&mut d1, &mut d2], 100.0);
assert_eq!(merged.count, 1000);
assert_eq!(merged.min, 1.0);
assert_eq!(merged.max, 1000.0);
let p50 = merged.quantile(0.5);
assert!(
(p50 - 500.0).abs() < 500.0 * 0.1,
"merged p50={p50}, expected ~500"
);
}
#[test]
fn trimmed_mean_iqr() {
let mut td = TDigest::new(200.0);
for i in 1..=1000 {
td.add(i as f64);
}
let tm = td.trimmed_mean(0.25, 0.75);
// IQR mean of uniform 1..1000 is ~500
assert!(
(tm - 500.0).abs() < 500.0 * 0.1,
"trimmed mean={tm}, expected ~500"
);
}
#[test]
fn rank_revrank_consistency() {
let mut td = TDigest::new(100.0);
for i in 1..=100 {
td.add(i as f64);
}
let r = td.rank(50.0);
let rr = td.rev_rank(50.0);
// rank + rev_rank + 1 should be approximately count
assert!(
(r + rr + 1 - 100).abs() <= 2,
"rank({r}) + rev_rank({rr}) + 1 != 100"
);
}
#[test]
fn negative_values_handled() {
let mut td = TDigest::new(100.0);
td.add(-100.0);
td.add(-50.0);
td.add(0.0);
td.add(50.0);
td.add(100.0);
assert_eq!(td.min, -100.0);
assert_eq!(td.max, 100.0);
let p50 = td.quantile(0.5);
assert!((p50 - 0.0).abs() < 50.0, "median should be ~0, got {p50}");
}
#[test]
fn nan_infinity_behaviour() {
// NaN in f64 comparisons doesn't update min/max properly,
// but the t-digest should handle normal values after NaN gracefully.
let mut td = TDigest::new(100.0);
// Add normal values first
td.add(1.0);
td.add(100.0);
// Verify normal operations work
let q = td.quantile(0.5);
assert!(!q.is_nan(), "quantile should be defined with normal values");
let c = td.cdf(50.0);
assert!(!c.is_nan(), "cdf should be defined with normal values");
}
#[test]
fn deserialize_too_short_returns_none() {
let data = vec![0u8; 10];
assert!(TDigest::deserialize(&data).is_none());
}
#[test]
fn by_rev_rank_edges() {
let mut td = TDigest::new(100.0);
td.add(10.0);
td.add(20.0);
td.add(30.0);
match td.by_rev_rank(-1) {
ByRankResult::Inf => {}
_ => panic!("expected Inf for negative revrank"),
}
match td.by_rev_rank(3) {
ByRankResult::NegInf => {}
_ => panic!("expected NegInf for revrank >= count"),
}
}
#[test]
fn min_max_on_empty_returns_nan() {
let td = TDigest::new(100.0);
assert!(td.min_val().is_nan());
assert!(td.max_val().is_nan());
}
// ==================================================================
// Deep NIF edge cases — targeting TDigest / FFI safety pitfalls
// ==================================================================
#[test]
fn add_positive_infinity_handled() {
let mut td = TDigest::new(100.0);
td.add(1.0);
td.add(f64::INFINITY);
assert_eq!(td.max, f64::INFINITY);
// Quantile should not crash
let _ = td.quantile(0.5);
}
#[test]
fn add_negative_infinity_handled() {
let mut td = TDigest::new(100.0);
td.add(1.0);
td.add(f64::NEG_INFINITY);
assert_eq!(td.min, f64::NEG_INFINITY);
let _ = td.quantile(0.5);
}
#[test]
fn add_nan_does_not_crash() {
let mut td = TDigest::new(100.0);
td.add(1.0);
td.add(f64::NAN);
// NaN pollutes min/max comparisons but must not crash
let _ = td.quantile(0.5);
let _ = td.cdf(0.5);
}
#[test]
fn quantile_negative_returns_min() {
let mut td = TDigest::new(100.0);
td.add(10.0);
td.add(20.0);
let q = td.quantile(-0.1);
// Negative quantile should clamp to min or return some defined value
// The key property is no crash
let _ = q;
}
#[test]
fn quantile_greater_than_1_returns_max() {
let mut td = TDigest::new(100.0);
td.add(10.0);
td.add(20.0);
let q = td.quantile(1.5);
// quantile > 1 should clamp to max or return some defined value
let _ = q;
}
#[test]
fn compression_1_minimum() {
let mut td = TDigest::new(1.0);
for i in 0..100 {
td.add(i as f64);
}
// Very few centroids with compression=1
td.ensure_compressed();
// Should still be able to answer queries
let p50 = td.quantile(0.5);
assert!(!p50.is_nan(), "quantile must be defined with compression=1");
}
#[test]
fn compression_10000_very_high() {
let mut td = TDigest::new(10000.0);
for i in 0..1000 {
td.add(i as f64);
}
td.ensure_compressed();
// With very high compression, many centroids, high accuracy
let p50 = td.quantile(0.5);
assert!(
(p50 - 500.0).abs() < 10.0,
"high compression should give accurate p50, got {p50}"
);
}
#[test]
fn million_identical_values() {
let mut td = TDigest::new(100.0);
for _ in 0..1_000_000 {
td.add(42.0);
}
assert_eq!(td.count, 1_000_000);
let p50 = td.quantile(0.5);
assert!(
(p50 - 42.0).abs() < 0.01,
"all identical values, p50 should be 42.0, got {p50}"
);
assert_eq!(td.min, 42.0);
assert_eq!(td.max, 42.0);
}
#[test]
fn alternating_min_max_values() {
let mut td = TDigest::new(100.0);
for i in 0..1000 {
if i % 2 == 0 {
td.add(0.0);
} else {
td.add(1000.0);
}
}
let p50 = td.quantile(0.5);
// Median should be around 500 (between the two extremes)
assert!(
(p50 - 500.0).abs() < 200.0,
"alternating min/max, p50 should be ~500, got {p50}"
);
}
#[test]
fn cdf_on_empty_returns_nan() {
let mut td = TDigest::new(100.0);
let c = td.cdf(50.0);
assert!(c.is_nan());
}
#[test]
fn quantile_0_returns_min() {
let mut td = TDigest::new(100.0);
td.add(5.0);
td.add(10.0);
td.add(15.0);
let q0 = td.quantile(0.0);
assert!(
(q0 - 5.0).abs() < 1.0,
"quantile(0) should be ~min, got {q0}"
);
}
#[test]
fn quantile_1_returns_max() {
let mut td = TDigest::new(100.0);
td.add(5.0);
td.add(10.0);
td.add(15.0);
let q1 = td.quantile(1.0);
assert!(
(q1 - 15.0).abs() < 1.0,
"quantile(1) should be ~max, got {q1}"
);
}
#[test]
fn merge_empty_digests() {
let mut d1 = TDigest::new(100.0);
let mut d2 = TDigest::new(100.0);
let merged = merge_digests(&mut [&mut d1, &mut d2], 100.0);
assert!(merged.is_empty());
}
#[test]
fn serialize_roundtrip_with_extremes() {
let mut td = TDigest::new(100.0);
td.add(f64::MIN);
td.add(0.0);
td.add(f64::MAX);
td.ensure_compressed();
let bytes = td.serialize();
let td2 = TDigest::deserialize(&bytes).unwrap();
assert_eq!(td2.count, 3);
assert_eq!(td2.min, f64::MIN);
assert_eq!(td2.max, f64::MAX);
}
#[test]
fn deserialize_truncated_at_every_byte_no_panic() {
let mut td = TDigest::new(100.0);
for i in 0..50 {
td.add(i as f64);
}
td.ensure_compressed();
let bytes = td.serialize();
for truncate_at in 0..bytes.len() {
let truncated = &bytes[..truncate_at];
let result = TDigest::deserialize(truncated);
// Must either succeed or return None, never panic
let _ = result;
}
}
#[test]
fn trimmed_mean_full_range_edge() {
let mut td = TDigest::new(100.0);
for i in 1..=1000 {
td.add(i as f64);
}
let tm = td.trimmed_mean(0.0, 1.0);
// Full range trimmed mean should be close to regular mean (~500.5)
assert!(
(tm - 500.5).abs() < 50.0,
"trimmed_mean(0,1) should be ~500.5, got {tm}"
);
}
#[test]
fn reset_and_reuse() {
let mut td = TDigest::new(100.0);
for i in 0..1000 {
td.add(i as f64);
}
assert_eq!(td.count, 1000);
td.reset();
assert!(td.is_empty());
assert_eq!(td.count, 0);
// Reuse after reset
td.add(42.0);
assert_eq!(td.count, 1);
let q = td.quantile(0.5);
assert!((q - 42.0).abs() < 0.1);
}
}