This is an automated email from the ASF dual-hosted git repository.
tisonkun pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datasketches-rust.git
The following commit(s) were added to refs/heads/main by this push:
new 8ba1022 feat(tdigest): Add batch quantile queries (#275)
8ba1022 is described below
commit 8ba102243a189d3281463ee1544fe0b682e73ca4
Author: Ryu <[email protected]>
AuthorDate: Sat Oct 3 18:16:15 2026 +0200
feat(tdigest): Add batch quantile queries (#275)
Co-authored-by: tison <[email protected]>
---
CHANGELOG.md | 2 +
benchmarks/tdigest/query.rs | 40 +++-
datasketches/src/lib.rs | 1 +
datasketches/src/tdigest/sketch.rs | 224 +++++++++++++++++------
tests-integration/tests/tdigest_test/property.rs | 48 ++++-
tests-integration/tests/tdigest_test/sketch.rs | 178 ++++++++++++++++++
6 files changed, 432 insertions(+), 61 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ff73f71..524755e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,7 @@ All significant changes to this project will be documented in
this file.
### New features
* `KllSketch` is now available behind the `kll` feature, with rank, quantile,
PMF, and CDF queries, merging, serialization, custom ordered item types, and a
`KllFloat` adapter for non-NaN floating-point values.
+* Add `TDigestMut::quantiles` and `TDigest::quantiles` for querying several
ranks in one centroid scan while preserving input order.
### Improvements
@@ -43,6 +44,7 @@ All significant changes to this project will be documented in
this file.
* `ReqSketch::cdf` and its sorted view now preserve the rank at each split
point and end at exactly `1.0`, avoiding extra rounding from accumulating PMF
values.
* `TDigestMut` updates and merges now panic without changing the digest if the
total weight would exceed `u64::MAX`, including in release builds.
* `TDigestMut::merge` now preserves the true minimum and maximum from both
inputs, including compressed digests.
+* `TDigest` and `TDigestMut` quantile queries no longer extrapolate above the
stored maximum when large total weights cause rounding near rank `1.0`.
* `TDigest` and `TDigestMut` deserialization now returns `InvalidData` for
invalid flags or extrema, out-of-range values, unsorted centroids, or non-empty
images with no stored values.
## v0.5.0 (2026-09-04)
diff --git a/benchmarks/tdigest/query.rs b/benchmarks/tdigest/query.rs
index 0981657..ae36977 100644
--- a/benchmarks/tdigest/query.rs
+++ b/benchmarks/tdigest/query.rs
@@ -18,6 +18,9 @@
use divan::Bencher;
use divan::black_box;
use divan::counter::ItemsCount;
+use rand::SeedableRng;
+use rand::rngs::StdRng;
+use rand::seq::SliceRandom;
use super::support::prepared_digest;
@@ -45,14 +48,37 @@ fn cdf_100(bencher: Bencher) {
.bench_local(|| black_box(&digest).cdf(black_box(&split_points)));
}
-#[divan::bench]
-fn quantiles_2_sequential(bencher: Bencher) {
+#[divan::bench(consts = [true, false], args = [2, 6, 100])]
+fn quantiles_sequential<const SORTED: bool>(bencher: Bencher, num_ranks:
usize) {
let digest = prepared_digest();
+ let ranks = query_ranks(num_ranks, SORTED);
- bencher.bench_local(|| {
- [
- black_box(&digest).quantile(black_box(0.5)),
- black_box(&digest).quantile(black_box(0.95)),
- ]
+ bencher.counter(ItemsCount::new(num_ranks)).bench_local(|| {
+ for &rank in black_box(&ranks) {
+ black_box(black_box(&digest).quantile(black_box(rank)));
+ }
});
}
+
+#[divan::bench(consts = [true, false], args = [2, 6, 100])]
+fn quantiles_batch<const SORTED: bool>(bencher: Bencher, num_ranks: usize) {
+ let digest = prepared_digest();
+ let ranks = query_ranks(num_ranks, SORTED);
+
+ bencher
+ .counter(ItemsCount::new(num_ranks))
+ .bench_local(|| black_box(&digest).quantiles(black_box(&ranks)));
+}
+
+fn query_ranks(num_ranks: usize, sorted: bool) -> Vec<f64> {
+ let mut ranks = (1..=num_ranks)
+ .map(|rank| rank as f64 / (num_ranks + 1) as f64)
+ .collect::<Vec<_>>();
+ if !sorted {
+ ranks.shuffle(&mut StdRng::seed_from_u64(42));
+ if ranks.is_sorted() {
+ ranks.reverse();
+ }
+ }
+ ranks
+}
diff --git a/datasketches/src/lib.rs b/datasketches/src/lib.rs
index b4ff016..6c0f539 100644
--- a/datasketches/src/lib.rs
+++ b/datasketches/src/lib.rs
@@ -63,6 +63,7 @@
#![cfg_attr(docsrs, feature(doc_cfg))]
#![deny(missing_docs)]
+extern crate core;
// See https://github.com/apache/datasketches-rust/issues/28 for more
information.
#[cfg(target_endian = "big")]
diff --git a/datasketches/src/tdigest/sketch.rs
b/datasketches/src/tdigest/sketch.rs
index e0d5953..317e61a 100644
--- a/datasketches/src/tdigest/sketch.rs
+++ b/datasketches/src/tdigest/sketch.rs
@@ -514,7 +514,10 @@ impl TDigestMut {
/// assert!((1.0..=3.0).contains(&median));
/// ```
pub fn quantile(&mut self, rank: f64) -> Option<f64> {
- assert!((0.0..=1.0).contains(&rank), "rank must be in [0.0, 1.0]");
+ assert!(
+ (0.0..=1.0).contains(&rank),
+ "rank must be in [0.0, 1.0]; got {rank}"
+ );
if self.is_empty() {
return None;
@@ -523,6 +526,21 @@ impl TDigestMut {
self.view().quantile(rank)
}
+ /// Returns the quantiles described by [`TDigest::quantiles`].
+ ///
+ /// # Panics
+ ///
+ /// Panics if any rank is outside `[0.0, 1.0]`.
+ pub fn quantiles(&mut self, ranks: &[f64]) -> Option<Vec<f64>> {
+ check_ranks(ranks);
+
+ if self.is_empty() {
+ return None;
+ }
+
+ self.view().quantiles(ranks)
+ }
+
/// Serializes this mutable t-digest to bytes.
///
/// # Examples
@@ -1274,6 +1292,35 @@ impl TDigest {
self.view().quantile(rank)
}
+ /// Computes approximate quantiles for the given normalized ranks.
+ ///
+ /// Ranks in nondecreasing order are answered with one centroid scan.
Ranks in any other order
+ /// are accepted and results are returned in the same order as the input.
+ ///
+ /// Returns `None` if this t-digest is empty.
+ ///
+ /// # Panics
+ ///
+ /// Panics if any rank is outside `[0.0, 1.0]`.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// use datasketches::tdigest::TDigestMut;
+ ///
+ /// let mut sketch = TDigestMut::new(100).unwrap();
+ /// for value in [1.0, 2.0, 3.0] {
+ /// sketch.update(value);
+ /// }
+ /// let digest = sketch.freeze();
+ /// let quantiles = digest.quantiles(&[0.25, 0.5, 0.75]).unwrap();
+ /// assert_eq!(quantiles.len(), 3);
+ /// ```
+ pub fn quantiles(&self, ranks: &[f64]) -> Option<Vec<f64>> {
+ check_ranks(ranks);
+ self.view().quantiles(ranks)
+ }
+
/// Converts this immutable t-digest into a mutable one.
///
/// # Examples
@@ -1441,57 +1488,132 @@ impl TDigestView<'_> {
return None;
}
+ let mut quantile = [0.];
+ self.fill_quantiles(std::iter::once((0, rank)), &mut quantile);
+ Some(quantile[0])
+ }
+
+ fn quantiles(&self, ranks: &[f64]) -> Option<Vec<f64>> {
+ debug_assert!(
+ ranks.iter().all(|rank| (0.0..=1.0).contains(rank)),
+ "ranks must be in [0.0, 1.0]"
+ );
+
+ if self.centroids.is_empty() {
+ return None;
+ }
+
+ let mut quantiles = vec![0.; ranks.len()];
+ if ranks.is_sorted() {
+ self.fill_quantiles(ranks.iter().copied().enumerate(), &mut
quantiles);
+ return Some(quantiles);
+ }
+
+ // The scan only moves forward. Sort indices so queries become
monotonic without changing
+ // the caller's output order.
+ let mut rank_order = (0..ranks.len()).collect::<Vec<_>>();
+ rank_order.sort_unstable_by(|&left, &right| {
+ // Ranks have already been validated, so neither value can be NaN.
+ ranks[left].partial_cmp(&ranks[right]).unwrap()
+ });
+ self.fill_quantiles(
+ rank_order.into_iter().map(|index| (index, ranks[index])),
+ &mut quantiles,
+ );
+ Some(quantiles)
+ }
+
+ /// Answers nondecreasing ranks at their original output indices.
+ fn fill_quantiles(
+ &self,
+ ranks: impl DoubleEndedIterator<Item = (usize, f64)>,
+ quantiles: &mut [f64],
+ ) {
if self.centroids.len() == 1 {
- return Some(self.centroids[0].mean);
+ quantiles.fill(self.centroids[0].mean);
+ return;
}
- // at least 2 centroids
let centroids_weight = self.centroids_weight as f64;
- let num_centroids = self.centroids.len();
- let weight = rank * centroids_weight;
- if weight < 1. {
- return Some(self.min);
- }
- if weight > centroids_weight - 1. {
- return Some(self.max);
- }
- let first_weight = self.centroids[0].weight();
- if first_weight > 1. && weight < first_weight / 2. {
- return Some(
- self.min
- + (((weight - 1.) / ((first_weight / 2.) - 1.))
- * (self.centroids[0].mean - self.min)),
- );
- }
- let last_weight = self.centroids[num_centroids - 1].weight();
- if last_weight > 1. && (centroids_weight - weight <= last_weight / 2.)
{
- if last_weight == 2. {
- return Some(self.max);
+ let mut queries = ranks
+ .map(|(index, rank)| (index, rank * centroids_weight))
+ .rev()
+ .peekable();
+
+ // Consume the right tail from the back, so the centroid scan can omit
all tail checks.
+ while let Some((index, _)) = queries.next_if(|&(_, weight)| weight >
centroids_weight - 1.)
+ {
+ quantiles[index] = self.max;
+ }
+ if queries.peek().is_none() {
+ return;
+ }
+
+ let last = self.centroids.last().unwrap();
+ let last_weight = last.weight();
+ if last_weight > 1. {
+ while let Some((index, weight)) =
+ queries.next_if(|&(_, weight)| centroids_weight - weight <=
last_weight / 2.)
+ {
+ quantiles[index] = if last_weight == 2. {
+ self.max
+ } else {
+ self.max
+ - (((centroids_weight - weight - 1.) / ((last_weight /
2.) - 1.))
+ * (self.max - last.mean))
+ };
+ }
+ }
+
+ let mut queries = queries.rev().peekable();
+ while let Some((index, _)) = queries.next_if(|&(_, weight)| weight <
1.) {
+ quantiles[index] = self.min;
+ }
+ if queries.peek().is_none() {
+ return;
+ }
+
+ let first = &self.centroids[0];
+ let first_weight = first.weight();
+ if first_weight > 1. {
+ while let Some((index, weight)) =
+ queries.next_if(|&(_, weight)| weight < first_weight / 2.)
+ {
+ quantiles[index] = self.min
+ + (((weight - 1.) / ((first_weight / 2.) - 1.)) *
(first.mean - self.min));
}
- return Some(
- self.max
- - (((centroids_weight - weight - 1.) / ((last_weight / 2.)
- 1.))
- * (self.max - self.centroids[num_centroids - 1].mean)),
- );
+ }
+ if queries.peek().is_none() {
+ return;
}
- // interpolate between extremes
+ // Answer the remaining queries by centroid interval; both streams
advance only forward.
let mut weight_so_far = first_weight / 2.;
- for i in 0..(num_centroids - 1) {
- let dw = (self.centroids[i].weight() + self.centroids[i +
1].weight()) / 2.;
- if weight_so_far + dw > weight {
- // the target weight is between centroids i and i+1
+ for pair in self.centroids.windows(2) {
+ let left = &pair[0];
+ let right = &pair[1];
+ let dw = (left.weight() + right.weight()) / 2.;
+ loop {
+ let Some(&(index, weight)) = queries.peek() else {
+ return;
+ };
+ if weight_so_far + dw <= weight {
+ break;
+ }
+ queries.next();
let mut left_weight = 0.;
- if self.centroids[i].weight.get() == 1 {
+ if left.weight.get() == 1 {
if weight - weight_so_far < 0.5 {
- return Some(self.centroids[i].mean);
+ quantiles[index] = left.mean;
+ continue;
}
left_weight = 0.5;
}
let mut right_weight = 0.;
- if self.centroids[i + 1].weight.get() == 1 {
+ if right.weight.get() == 1 {
if weight_so_far + dw - weight <= 0.5 {
- return Some(self.centroids[i + 1].mean);
+ quantiles[index] = right.mean;
+ continue;
}
right_weight = 0.5;
}
@@ -1499,24 +1621,16 @@ impl TDigestView<'_> {
// centroid, so the estimate approaches the nearer one.
let distance_from_left = weight - weight_so_far - left_weight;
let distance_to_right = weight_so_far + dw - weight -
right_weight;
- return Some(weighted_average(
- self.centroids[i].mean,
- distance_to_right,
- self.centroids[i + 1].mean,
- distance_from_left,
- ));
+ quantiles[index] =
+ weighted_average(left.mean, distance_to_right, right.mean,
distance_from_left);
}
weight_so_far += dw;
}
- let w1 = weight - (centroids_weight) - ((self.centroids[num_centroids
- 1].weight()) / 2.);
- let w2 = (self.centroids[num_centroids - 1].weight() / 2.) - w1;
- Some(weighted_average(
- self.centroids[num_centroids - 1].mean,
- w1,
- self.max,
- w2,
- ))
+ // Rounding at large total weights can exhaust the scan near the
maximum.
+ for (index, _) in queries {
+ quantiles[index] = self.max;
+ }
}
}
@@ -1532,6 +1646,14 @@ fn check_split_points(split_points: &[f64]) {
}
}
+#[track_caller]
+fn check_ranks(ranks: &[f64]) {
+ assert!(
+ ranks.iter().all(|rank| (0.0..=1.0).contains(rank)),
+ "ranks must be in [0.0, 1.0]; got {ranks:?}"
+ );
+}
+
fn centroid_cmp(a: &Centroid, b: &Centroid) -> Ordering {
match a.mean.partial_cmp(&b.mean) {
Some(order) => order,
diff --git a/tests-integration/tests/tdigest_test/property.rs
b/tests-integration/tests/tdigest_test/property.rs
index 28ac0b4..6e5fbc1 100644
--- a/tests-integration/tests/tdigest_test/property.rs
+++ b/tests-integration/tests/tdigest_test/property.rs
@@ -36,10 +36,13 @@ fn assert_quantiles_are_monotonic(tdigest: &mut TDigestMut)
{
let min = tdigest.min_value().unwrap();
let max = tdigest.max_value().unwrap();
let mut previous = min;
+ let ranks = (0..=RANK_STEPS)
+ .map(|step| step as f64 / RANK_STEPS as f64)
+ .collect::<Vec<_>>();
+ let quantiles = tdigest.quantiles(&ranks).unwrap();
- for step in 0..=RANK_STEPS {
- let rank = step as f64 / RANK_STEPS as f64;
- let quantile = tdigest.quantile(rank).unwrap();
+ for (&rank, quantile) in ranks.iter().zip(quantiles) {
+ assert_eq!(tdigest.quantile(rank), Some(quantile));
assert!(
(previous..=max).contains(&quantile),
"quantile {quantile} at rank {rank} is outside [{previous}, {max}]"
@@ -87,3 +90,42 @@ fn prop_merged_quantile_is_non_decreasing() {
.rng(Gen::new(900))
.quickcheck(property as fn(Vec<u32>, Vec<u32>) -> TestResult);
}
+
+#[test]
+fn prop_batch_quantiles_preserve_input_order_and_duplicates() {
+ fn property(values: Vec<u32>, ranks: Vec<u8>) {
+ let tdigest = digest_of(&values);
+ let frozen = tdigest.clone().freeze();
+ let mut ranks = ranks
+ .into_iter()
+ .map(|rank| f64::from(rank) / f64::from(u8::MAX))
+ .collect::<Vec<_>>();
+ if let Some(&rank) = ranks.first() {
+ ranks.push(rank);
+ }
+ ranks.extend_from_slice(&[1.0, 0.0, -0.0]);
+
+ for sorted in [false, true] {
+ if sorted {
+ ranks.sort_unstable_by(f64::total_cmp);
+ }
+ let expected = if frozen.is_empty() {
+ None
+ } else {
+ Some(
+ ranks
+ .iter()
+ .map(|&rank| frozen.quantile(rank).unwrap())
+ .collect::<Vec<_>>(),
+ )
+ };
+ assert_eq!(tdigest.clone().quantiles(&ranks), expected);
+ assert_eq!(frozen.quantiles(&ranks), expected);
+ }
+ }
+
+ QuickCheck::new()
+ .tests(256)
+ .rng(Gen::new(1200))
+ .quickcheck(property as fn(Vec<u32>, Vec<u8>));
+}
diff --git a/tests-integration/tests/tdigest_test/sketch.rs
b/tests-integration/tests/tdigest_test/sketch.rs
index 38a94b5..e35d2d6 100644
--- a/tests-integration/tests/tdigest_test/sketch.rs
+++ b/tests-integration/tests/tdigest_test/sketch.rs
@@ -35,6 +35,8 @@ fn test_empty() {
assert_eq!(tdigest.max_value(), None);
assert_eq!(tdigest.rank(0.0), None);
assert_eq!(tdigest.quantile(0.5), None);
+ assert_eq!(tdigest.quantiles(&[0.0, 0.5, 1.0]), None);
+ assert_eq!(tdigest.quantiles(&[]), None);
let split_points = [0.0];
assert_eq!(tdigest.pmf(&split_points), None);
@@ -48,6 +50,8 @@ fn test_empty() {
assert_eq!(tdigest.max_value(), None);
assert_eq!(tdigest.rank(0.0), None);
assert_eq!(tdigest.quantile(0.5), None);
+ assert_eq!(tdigest.quantiles(&[0.0, 0.5, 1.0]), None);
+ assert_eq!(tdigest.quantiles(&[]), None);
let split_points = [0.0];
assert_eq!(tdigest.pmf(&split_points), None);
@@ -68,6 +72,12 @@ fn test_one_value() {
assert_eq!(tdigest.quantile(0.0), Some(1.0));
assert_eq!(tdigest.quantile(0.5), Some(1.0));
assert_eq!(tdigest.quantile(1.0), Some(1.0));
+ let ranks = [1.0, 0.5, 0.0, 0.5];
+ assert_eq!(tdigest.quantiles(&ranks), Some(vec![1.0; ranks.len()]));
+ assert_eq!(
+ tdigest.freeze().quantiles(&ranks),
+ Some(vec![1.0; ranks.len()])
+ );
}
#[test]
@@ -360,6 +370,24 @@ fn deserialize_with_centroids(k: u16, min: f64, max: f64,
centroids: &[(f64, u64
TDigestMut::deserialize(&bytes).unwrap()
}
+fn assert_quantile_queries(tdigest: &TDigestMut, ranks: &[f64], expected:
&[f64]) {
+ let frozen = tdigest.clone().freeze();
+ for actual in [
+ tdigest.clone().quantiles(ranks).unwrap(),
+ frozen.quantiles(ranks).unwrap(),
+ ] {
+ assert_eq!(actual.len(), expected.len());
+ for (actual, &expected) in actual.into_iter().zip(expected) {
+ assert_that!(actual, near(expected, 1e-12));
+ }
+ }
+ let mut mutable = tdigest.clone();
+ for (&rank, &expected) in ranks.iter().zip(expected) {
+ assert_that!(mutable.quantile(rank).unwrap(), near(expected, 1e-12));
+ assert_that!(frozen.quantile(rank).unwrap(), near(expected, 1e-12));
+ }
+}
+
#[test]
fn test_quantile_moves_toward_the_nearer_bracketing_centroid() {
let mut tdigest =
@@ -395,6 +423,156 @@ fn test_quantile_handles_two_sample_last_centroid() {
assert_eq!(tdigest.quantile(0.75), Some(100.0));
}
+#[test]
+fn test_batch_quantiles_match_scalar_queries_in_input_order() {
+ let mut tdigest = TDigestMut::new(100).unwrap();
+ for value in 0..10_000 {
+ tdigest.update(((value * 37) % 1_003) as f64);
+ }
+
+ let frozen = tdigest.clone().freeze();
+ for ranks in [
+ vec![-0.0, 0.0, 0.001, 0.25, 0.5, 0.5, 0.99, 1.0],
+ vec![0.99, 0.0, 0.5, 1.0, 0.001, -0.0, 0.5, 0.25],
+ vec![1.0, 0.99, 0.5, 0.5, 0.25, 0.001, 0.0],
+ vec![],
+ ] {
+ let expected = ranks
+ .iter()
+ .map(|&rank| frozen.quantile(rank).unwrap())
+ .collect::<Vec<_>>();
+ // Each mutable batch starts with the original buffered values still
pending.
+ assert_eq!(tdigest.clone().quantiles(&ranks), Some(expected.clone()));
+ assert_eq!(frozen.quantiles(&ranks), Some(expected));
+ }
+}
+
+#[test]
+fn test_batch_quantiles_cross_centroid_and_tail_boundaries() {
+ let tdigest =
+ deserialize_with_centroids(100, 0.0, 100.0, &[(10.0, 10), (50.0, 10),
(90.0, 10)]);
+ // Query masses span both tails and the centers of all three centroids.
+ let queries = [
+ (0.0, 0.0),
+ (1.0, 0.0),
+ (3.0, 5.0),
+ (5.0, 10.0),
+ (10.0, 30.0),
+ (15.0, 50.0),
+ (15.0, 50.0),
+ (20.0, 70.0),
+ (25.0, 90.0),
+ (27.0, 95.0),
+ (29.0, 100.0),
+ (30.0, 100.0),
+ ];
+ let mut reversed = queries;
+ reversed.reverse();
+ let mut unordered = queries;
+ unordered.rotate_left(5);
+
+ for queries in [queries, reversed, unordered] {
+ let ranks = queries.map(|(weight, _)| weight / 30.0);
+ let expected = queries.map(|(_, quantile)| quantile);
+ assert_quantile_queries(&tdigest, &ranks, &expected);
+ }
+ // Batches confined to one region can finish before reaching the centroid
scan.
+ for (weight, expected) in queries {
+ assert_quantile_queries(&tdigest, &[weight / 30.0; 3], &[expected; 3]);
+ }
+}
+
+#[test]
+fn test_quantiles_respect_two_sample_tail_boundaries() {
+ let tdigest = deserialize_with_centroids(100, 0.0, 100.0, &[(10.0, 2),
(90.0, 2)]);
+ // These tails are discontinuous: the left endpoint steps to the first
mean, while the right
+ // endpoint steps from the last mean to max. Neither tail may divide by a
zero span.
+ let ranks = [
+ 0.0,
+ 0.25_f64.next_down(),
+ 0.25,
+ 0.25_f64.next_up(),
+ 0.5,
+ 0.75_f64.next_down(),
+ 0.75,
+ 0.75,
+ 0.75_f64.next_up(),
+ 1.0,
+ ];
+ let expected = [0.0, 0.0, 10.0, 10.0, 50.0, 90.0, 100.0, 100.0, 100.0,
100.0];
+ assert_quantile_queries(&tdigest, &ranks, &expected);
+}
+
+#[test]
+fn test_quantiles_interpolate_around_singleton_centroids() {
+ let tdigest = deserialize_with_centroids(
+ 100,
+ 0.0,
+ 60.0,
+ &[(0.0, 1), (10.0, 3), (20.0, 1), (40.0, 5), (60.0, 1)],
+ );
+ let ranks = [
+ 0.0, 0.75, 1.0, 1.75, 2.5, 3.25, 4.0, 4.5, 4.75, 5.0, 6.25, 7.5, 8.75,
10.0, 11.0,
+ ]
+ .map(|weight| weight / 11.0);
+ let expected = [
+ 0.0, 0.0, 0.0, 5.0, 10.0, 15.0, 20.0, 20.0, 20.0, 20.0, 30.0, 40.0,
50.0, 60.0, 60.0,
+ ];
+ assert_quantile_queries(&tdigest, &ranks, &expected);
+}
+
+#[test]
+fn test_quantiles_stay_within_extrema_at_large_total_weights() {
+ for total_weight in [1_u64 << 53, 1_u64 << 54, u64::MAX] {
+ let tdigest = deserialize_with_centroids(
+ 100,
+ 0.0,
+ 100.0,
+ &[(10.0, total_weight - 2), (50.0, 1), (90.0, 1)],
+ );
+ let frozen = tdigest.clone().freeze();
+ let ranks = [0.0, 0.5, 1.0_f64.next_down(), 1.0, 1.0];
+ for quantiles in [
+ tdigest.clone().quantiles(&ranks).unwrap(),
+ frozen.quantiles(&ranks).unwrap(),
+ ranks.map(|rank| frozen.quantile(rank).unwrap()).to_vec(),
+ ] {
+ assert_eq!(quantiles[0], 0.0);
+ assert_eq!(&quantiles[3..], &[100.0, 100.0]);
+ assert!(
+ quantiles.is_sorted(),
+ "weight {total_weight}: {quantiles:?}"
+ );
+ assert!(
+ quantiles.iter().all(|value| (0.0..=100.0).contains(value)),
+ "weight {total_weight}: {quantiles:?}"
+ );
+ }
+ }
+}
+
+#[test]
+fn test_batch_quantiles_reject_invalid_ranks() {
+ for values in [&[][..], &[1.0, 2.0, 3.0][..]] {
+ let mut tdigest = TDigestMut::default();
+ for &value in values {
+ tdigest.update(value);
+ }
+ let frozen = tdigest.clone().freeze();
+ for invalid in [
+ -f64::EPSILON,
+ 1.0 + f64::EPSILON,
+ f64::NAN,
+ f64::NEG_INFINITY,
+ f64::INFINITY,
+ ] {
+ let ranks = [0.5, invalid];
+ assert!(catch_unwind(AssertUnwindSafe(||
tdigest.quantiles(&ranks))).is_err());
+ assert!(catch_unwind(|| frozen.quantiles(&ranks)).is_err());
+ }
+ }
+}
+
#[test]
fn test_rank_left_tail_is_a_fraction_of_the_total_weight() {
let mut tdigest =
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]