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 618ea2e feat(tdigest): Add owned batch merging (#276)
618ea2e is described below
commit 618ea2e842b1029eb77e741a75f923e59cb3018c
Author: Ryu <[email protected]>
AuthorDate: Sun Oct 4 07:43:18 2026 +0200
feat(tdigest): Add owned batch merging (#276)
Co-authored-by: tison <[email protected]>
---
CHANGELOG.md | 1 +
benchmarks/tdigest/aggregation.rs | 130 +++++++++++
benchmarks/tdigest/merge.rs | 125 +++++++++-
benchmarks/tdigest/mod.rs | 1 +
datasketches/src/tdigest/mod.rs | 2 +-
datasketches/src/tdigest/sketch.rs | 303 +++++++++++++++++++++++--
tests-integration/tests/tdigest_test/sketch.rs | 240 ++++++++++++++++++++
7 files changed, 784 insertions(+), 18 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 524755e..24934e7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,7 @@ All significant changes to this project will be documented in
this file.
* `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.
+* Add `FromIterator<TDigestMut>` for batch construction from owned partial
sketches, avoiding repeated compression of intermediate results.
### Improvements
diff --git a/benchmarks/tdigest/aggregation.rs
b/benchmarks/tdigest/aggregation.rs
new file mode 100644
index 0000000..da17069
--- /dev/null
+++ b/benchmarks/tdigest/aggregation.rs
@@ -0,0 +1,130 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use datasketches::tdigest::TDigestMut;
+use divan::Bencher;
+use divan::black_box;
+use divan::counter::ItemsCount;
+
+use super::support::build_mut_digest;
+use super::support::values;
+
+// (number of partial states, values per state). Each partial samples the same
distribution.
+const SERIALIZED_INPUTS: &[(usize, usize)] =
+ &[(1, 8), (64, 8), (512, 8), (64, 64), (512, 64), (64, 4_096)];
+
+#[divan::bench(args = SERIALIZED_INPUTS, sample_size = 1)]
+fn serialized_borrowed(bencher: Bencher, shape: (usize, usize)) {
+ let (num_partials, rows_per_partial) = shape;
+ let partials = serialize_partials(&values(num_partials *
rows_per_partial), rows_per_partial);
+
+ bencher
+ .counter(ItemsCount::new(num_partials * rows_per_partial))
+ .bench_local(|| {
+ let mut merged = TDigestMut::default();
+ for bytes in black_box(&partials) {
+ merged.merge(&TDigestMut::deserialize(bytes).unwrap());
+ }
+ final_quantile(merged)
+ });
+}
+
+#[divan::bench(args = SERIALIZED_INPUTS, sample_size = 1)]
+fn serialized_owned(bencher: Bencher, shape: (usize, usize)) {
+ let (num_partials, rows_per_partial) = shape;
+ let partials = serialize_partials(&values(num_partials *
rows_per_partial), rows_per_partial);
+
+ bencher
+ .counter(ItemsCount::new(num_partials * rows_per_partial))
+ .bench_local(|| {
+ let merged = black_box(&partials)
+ .iter()
+ .map(|bytes| TDigestMut::deserialize(bytes))
+ .collect::<Result<TDigestMut, _>>()
+ .unwrap();
+ final_quantile(merged)
+ });
+}
+
+#[divan::bench(args = SERIALIZED_INPUTS, sample_size = 1)]
+fn serialized_in_batches(bencher: Bencher, shape: (usize, usize)) {
+ let (num_partials, rows_per_partial) = shape;
+ let partials = serialize_partials(&values(num_partials *
rows_per_partial), rows_per_partial);
+
+ bencher
+ .counter(ItemsCount::new(num_partials * rows_per_partial))
+ .bench_local(|| {
+ let mut merged = TDigestMut::default();
+ for batch in black_box(&partials).chunks(16) {
+ let batch = batch
+ .iter()
+ .map(|bytes| TDigestMut::deserialize(bytes))
+ .collect::<Result<TDigestMut, _>>()
+ .unwrap();
+ merged.merge(&batch);
+ }
+ final_quantile(merged)
+ });
+}
+
+#[divan::bench(args = [8, 64], sample_size = 1)]
+fn end_to_end_borrowed(bencher: Bencher, rows_per_partial: usize) {
+ let inputs = values(64 * rows_per_partial);
+
+ bencher
+ .counter(ItemsCount::new(inputs.len()))
+ .bench_local(|| {
+ let partials = serialize_partials(black_box(&inputs),
rows_per_partial);
+ let mut merged = TDigestMut::default();
+ for bytes in &partials {
+ merged.merge(&TDigestMut::deserialize(bytes).unwrap());
+ }
+ final_quantile(merged)
+ });
+}
+
+#[divan::bench(args = [8, 64], sample_size = 1)]
+fn end_to_end_owned(bencher: Bencher, rows_per_partial: usize) {
+ let inputs = values(64 * rows_per_partial);
+
+ bencher
+ .counter(ItemsCount::new(inputs.len()))
+ .bench_local(|| {
+ let partials = serialize_partials(black_box(&inputs),
rows_per_partial);
+ let merged = partials
+ .iter()
+ .map(|bytes| TDigestMut::deserialize(bytes))
+ .collect::<Result<TDigestMut, _>>()
+ .unwrap();
+ final_quantile(merged)
+ });
+}
+
+fn serialize_partials(inputs: &[f64], rows_per_partial: usize) -> Vec<Vec<u8>>
{
+ inputs
+ .chunks(rows_per_partial)
+ .map(|values| build_mut_digest(values).serialize())
+ .collect()
+}
+
+fn final_quantile(mut merged: TDigestMut) -> Option<f64> {
+ // A later aggregation stage decodes the serialized merge result.
+ let bytes = merged.serialize();
+ TDigestMut::deserialize(black_box(&bytes))
+ .unwrap()
+ .quantile(black_box(0.9))
+}
diff --git a/benchmarks/tdigest/merge.rs b/benchmarks/tdigest/merge.rs
index 13559c6..12f6f8b 100644
--- a/benchmarks/tdigest/merge.rs
+++ b/benchmarks/tdigest/merge.rs
@@ -65,7 +65,7 @@ fn small_partials(bencher: Bencher) {
let partials = partial_digests(64, SMALL_ROWS_PER_PARTIAL)
.into_iter()
.map(|mut digest| {
- black_box(digest.rank(0.0));
+ black_box(digest.quantile(0.5));
digest
})
.collect::<Vec<_>>();
@@ -86,7 +86,7 @@ fn partials(bencher: Bencher) {
let partials = partial_digests_with(DEFAULT_DIGEST_K, 64, ROWS_PER_PARTIAL)
.into_iter()
.map(|mut digest| {
- black_box(digest.rank(0.0));
+ black_box(digest.quantile(0.5));
digest
})
.collect::<Vec<_>>();
@@ -102,6 +102,80 @@ fn partials(bencher: Bencher) {
});
}
+#[divan::bench]
+fn partials_from_iter(bencher: Bencher) {
+ let partials = partial_digests_with(DEFAULT_DIGEST_K, 64, ROWS_PER_PARTIAL)
+ .into_iter()
+ .map(|mut digest| {
+ black_box(digest.quantile(0.5));
+ digest
+ })
+ .collect::<Vec<_>>();
+
+ bencher
+ .counter(ItemsCount::new(64 * ROWS_PER_PARTIAL))
+ // Construct fresh owned inputs outside the measurement, as a real
caller transfers
+ // ownership rather than cloning solely for `FromIterator`.
+ .with_inputs(|| partials.clone())
+ .bench_local_values(|partials|
black_box(partials).into_iter().collect::<TDigestMut>());
+}
+
+#[divan::bench]
+fn partials_from_iter_cloned(bencher: Bencher) {
+ let partials = partial_digests_with(DEFAULT_DIGEST_K, 64, ROWS_PER_PARTIAL)
+ .into_iter()
+ .map(|mut digest| {
+ black_box(digest.quantile(0.5));
+ digest
+ })
+ .collect::<Vec<_>>();
+
+ bencher
+ .counter(ItemsCount::new(64 * ROWS_PER_PARTIAL))
+ // Include cloning when the caller needs to retain the original inputs.
+ .bench_local(||
black_box(&partials).iter().cloned().collect::<TDigestMut>());
+}
+
+#[divan::bench(args = [0, 64, 4_096])]
+fn partials_from_iter_finalize(bencher: Bencher, additional_rows: usize) {
+ let partials = partial_digests_with(DEFAULT_DIGEST_K, 64, ROWS_PER_PARTIAL)
+ .into_iter()
+ .map(|mut digest| {
+ black_box(digest.quantile(0.5));
+ digest
+ })
+ .collect::<Vec<_>>();
+ let updates = values(additional_rows)
+ .into_iter()
+ .map(|value| value * (64 * ROWS_PER_PARTIAL) as f64)
+ .collect::<Vec<_>>();
+
+ bencher
+ .counter(ItemsCount::new(64 * ROWS_PER_PARTIAL + additional_rows))
+ .with_inputs(|| partials.clone())
+ .bench_local_values(|partials| {
+ let mut merged =
black_box(partials).into_iter().collect::<TDigestMut>();
+ for &value in black_box(&updates) {
+ merged.update(value);
+ }
+ black_box(merged.freeze())
+ });
+}
+
+#[divan::bench]
+fn uncompressed_partials_from_iter(bencher: Bencher) {
+ let values = values(64 * ROWS_PER_PARTIAL);
+ let partials = values
+ .chunks_exact(ROWS_PER_PARTIAL)
+ .map(build_mut_digest)
+ .collect::<Vec<_>>();
+
+ bencher
+ .counter(ItemsCount::new(values.len()))
+ .with_inputs(|| partials.clone())
+ .bench_local_values(|partials|
black_box(partials).into_iter().collect::<TDigestMut>());
+}
+
#[divan::bench(args = [SMALL_ROWS_PER_PARTIAL, ROWS_PER_PARTIAL])]
fn serialized_partials(bencher: Bencher, rows_per_partial: usize) {
let partials = serialized_partial_digests(64, rows_per_partial);
@@ -137,3 +211,50 @@ fn serialized_overlapping_partials(bencher: Bencher) {
black_box(merged.quantile(0.5))
});
}
+
+#[divan::bench(args = [false, true])]
+fn serialized_partials_from_iter(bencher: Bencher, overlapping: bool) {
+ let partials = serialized_merge_inputs(overlapping);
+
+ bencher
+ .counter(ItemsCount::new(64 * ROWS_PER_PARTIAL))
+ .bench_local(|| {
+ let mut merged = black_box(&partials)
+ .iter()
+ .map(|partial| TDigestMut::deserialize(partial))
+ .collect::<Result<TDigestMut, _>>()
+ .unwrap();
+ black_box(merged.quantile(0.5))
+ });
+}
+
+#[divan::bench(args = [false, true])]
+fn serialized_partials_in_batches(bencher: Bencher, overlapping: bool) {
+ let partials = serialized_merge_inputs(overlapping);
+
+ bencher
+ .counter(ItemsCount::new(64 * ROWS_PER_PARTIAL))
+ .bench_local(|| {
+ let mut merged = TDigestMut::new(DEFAULT_DIGEST_K).unwrap();
+ for batch in black_box(&partials).chunks(16) {
+ let batch = batch
+ .iter()
+ .map(|partial| TDigestMut::deserialize(partial))
+ .collect::<Result<TDigestMut, _>>()
+ .unwrap();
+ merged.merge(&batch);
+ }
+ black_box(merged.quantile(0.5))
+ });
+}
+
+fn serialized_merge_inputs(overlapping: bool) -> Vec<Vec<u8>> {
+ if overlapping {
+ values(64 * ROWS_PER_PARTIAL)
+ .chunks_exact(ROWS_PER_PARTIAL)
+ .map(|values| build_mut_digest(values).serialize())
+ .collect()
+ } else {
+ serialized_partial_digests(64, ROWS_PER_PARTIAL)
+ }
+}
diff --git a/benchmarks/tdigest/mod.rs b/benchmarks/tdigest/mod.rs
index adf1883..ce18622 100644
--- a/benchmarks/tdigest/mod.rs
+++ b/benchmarks/tdigest/mod.rs
@@ -15,6 +15,7 @@
// specific language governing permissions and limitations
// under the License.
+mod aggregation;
mod compress;
mod merge;
mod query;
diff --git a/datasketches/src/tdigest/mod.rs b/datasketches/src/tdigest/mod.rs
index 7d07de8..0d46618 100644
--- a/datasketches/src/tdigest/mod.rs
+++ b/datasketches/src/tdigest/mod.rs
@@ -23,7 +23,7 @@
//! The implementation in this library has a few differences from the
reference implementation
//! associated with that paper:
//!
-//! * Merging does not modify the input.
+//! * [`TDigestMut::merge`] borrows its input without modifying it.
//! * Deserialization is similar to other sketches in this library, although
reading the reference
//! implementation format is supported.
//!
diff --git a/datasketches/src/tdigest/sketch.rs
b/datasketches/src/tdigest/sketch.rs
index 317e61a..eb31e87 100644
--- a/datasketches/src/tdigest/sketch.rs
+++ b/datasketches/src/tdigest/sketch.rs
@@ -119,11 +119,10 @@ impl TDigestBuffer {
/// Combines this buffer with a non-empty borrowed buffer in stable mean
order.
fn into_merged_centroids(mut self, other: &TDigestBuffer) -> Vec<Centroid>
{
debug_assert!(!other.is_empty(), "an empty right-hand buffer is a
no-op");
- if self.unmerged_tail_len == 0
- && other.unmerged_tail_len == 0
- && centroids_are_sorted(&self.centroids)
- && centroids_are_sorted(&other.centroids)
- {
+ if self.unmerged_tail_len == 0 && other.unmerged_tail_len == 0 {
+ // Compression and deserialization both establish this invariant.
+ debug_assert!(centroids_are_sorted(&self.centroids));
+ debug_assert!(centroids_are_sorted(&other.centroids));
merge_sorted_centroids(&mut self.centroids, &other.centroids);
return self.centroids;
}
@@ -327,6 +326,10 @@ impl TDigestMut {
/// Merges the given t-digest into this one.
///
+ /// Retains this digest's `k`, even if the other digest uses a different
value.
+ /// The borrowed input remains available for reuse. Collecting owned
inputs into a
+ /// [`TDigestMut`] with [`Iterator::collect`] combines a batch with one
compression pass.
+ ///
/// # Panics
///
/// Panics without modifying the digest if the combined total weight would
exceed `u64::MAX`.
@@ -943,16 +946,14 @@ impl TDigestMut {
while current < len {
let c = centroids[current];
let proposed_weight = centroids[num_centroids - 1].weight() +
c.weight();
- let mut add_this = false;
- if (current != 1) && (current != (len - 1)) {
- let q0 = weight_so_far / compressed_weight;
- let q2 = (weight_so_far + proposed_weight) / compressed_weight;
- add_this = proposed_weight
- <= (compressed_weight
- * scale_function::max(q0, normalizer)
- .min(scale_function::max(q2, normalizer)));
- }
- if add_this {
+ if should_merge_centroid(
+ current,
+ len,
+ weight_so_far,
+ proposed_weight,
+ compressed_weight,
+ normalizer,
+ ) {
// merge into existing centroid
centroids[num_centroids - 1].add(c);
} else {
@@ -992,6 +993,242 @@ impl TDigestMut {
}
}
+/// Collects owned t-digests into one result with a single compression pass.
+///
+/// Empty inputs are ignored; if all inputs are empty, returns
[`TDigestMut::default()`]. The result
+/// uses the smallest `k` among the non-empty inputs. A single non-empty input
is returned
+/// unchanged.
+///
+/// Collecting consumes each digest without cloning it. Callers that need to
retain their inputs
+/// can use [`TDigestMut::merge`] or explicitly clone them before collection.
+///
+/// Collection retains all non-empty input buffers before compression, even
with a lazy iterator.
+/// Fully compressed inputs are merged directly into the result; inputs with
buffered updates also
+/// require a combined centroid buffer and sorting workspace. Repeated `merge`
calls or smaller
+/// batches limit the number of inputs held at once, at the cost of additional
compression passes.
+/// Different merge groupings can produce different estimates.
+///
+/// # Panics
+///
+/// Panics if the combined total weight exceeds `u64::MAX` or the combined
centroid count exceeds
+/// `usize::MAX`.
+///
+/// # Examples
+///
+/// ```
+/// use datasketches::tdigest::TDigestMut;
+///
+/// let partials = [1.0, 2.0].map(|value| {
+/// let mut digest = TDigestMut::new(100).unwrap();
+/// digest.update(value);
+/// digest
+/// });
+/// let merged = partials.into_iter().collect::<TDigestMut>();
+/// assert_eq!(merged.total_weight(), 2);
+/// ```
+///
+/// Serialized states can be decoded in batches, retaining only one batch of
decoded inputs at a
+/// time. A batch size limits the number of digests, not their byte size; a
strict memory budget
+/// needs to account for varying input sizes and merge workspace.
+///
+/// ```
+/// use datasketches::tdigest::TDigestMut;
+///
+/// let partial_states = [1.0, 2.0, 3.0, 4.0].map(|value| {
+/// let mut digest = TDigestMut::new(100).unwrap();
+/// digest.update(value);
+/// digest.serialize()
+/// });
+///
+/// let mut merged = TDigestMut::new(100).unwrap();
+/// for batch in partial_states.chunks(2) {
+/// let batch = batch
+/// .iter()
+/// .map(|bytes| TDigestMut::deserialize(bytes))
+/// .collect::<Result<TDigestMut, _>>()
+/// .unwrap();
+/// merged.merge(&batch);
+/// }
+/// assert_eq!(merged.total_weight(), 4);
+/// assert_eq!(merged.min_value(), Some(1.0));
+/// assert_eq!(merged.max_value(), Some(4.0));
+/// ```
+impl FromIterator<TDigestMut> for TDigestMut {
+ fn from_iter<T: IntoIterator<Item = TDigestMut>>(iter: T) -> Self {
+ let mut digests = iter.into_iter();
+ let Some(first) = digests.find(|digest| !digest.is_empty()) else {
+ return TDigestMut::default();
+ };
+ let Some(second) = digests.find(|digest| !digest.is_empty()) else {
+ return first;
+ };
+
+ // Each entry owns its input buffer and releases it when that input is
exhausted.
+ struct MergeInput {
+ // Cache the next centroid so heap comparisons only touch heap
storage.
+ next_centroid: Centroid,
+ centroids: std::vec::IntoIter<Centroid>,
+ unmerged_len: usize,
+ input_index: usize,
+ }
+
+ fn sift_down_merge_heap(heap: &mut [MergeInput], mut position: usize,
reverse: bool) {
+ // Equal means follow input order, as in a stable sort of
concatenated inputs.
+ // Reverse compression reverses that entire order, including ties.
+ let precedes = |left: &MergeInput, right: &MergeInput| match
centroid_cmp(
+ &left.next_centroid,
+ &right.next_centroid,
+ ) {
+ Ordering::Less => !reverse,
+ Ordering::Greater => reverse,
+ Ordering::Equal if reverse => left.input_index >
right.input_index,
+ Ordering::Equal => left.input_index < right.input_index,
+ };
+ loop {
+ let left = (position * 2) + 1;
+ if left >= heap.len() {
+ return;
+ }
+ let right = left + 1;
+ let next = if right < heap.len() && precedes(&heap[right],
&heap[left]) {
+ right
+ } else {
+ left
+ };
+ if !precedes(&heap[next], &heap[position]) {
+ return;
+ }
+ heap.swap(position, next);
+ position = next;
+ }
+ }
+
+ let reverse = first.reverse_merge;
+ let mut merged = TDigestMut::make(
+ first.k,
+ reverse,
+ first.min,
+ first.max,
+ TDigestBuffer::default(),
+ 0,
+ );
+ let digests = [first, second].into_iter().chain(digests);
+ let mut heap = Vec::with_capacity(digests.size_hint().0);
+ let mut total_weight = 0u64;
+ let mut num_centroids = 0usize;
+ let mut all_compressed = true;
+ for (input_index, digest) in digests.enumerate() {
+ if digest.is_empty() {
+ continue;
+ }
+ total_weight = total_weight
+ .checked_add(digest.total_weight())
+ .expect("combined t-digest weight exceeds u64::MAX");
+ num_centroids = num_centroids
+ .checked_add(digest.buffer.len())
+ .expect("combined t-digest centroid count exceeds usize::MAX");
+ merged.k = merged.k.min(digest.k);
+ merged.min = merged.min.min(digest.min);
+ merged.max = merged.max.max(digest.max);
+ let unmerged_len = digest.buffer.unmerged_len();
+ all_compressed &= unmerged_len == 0;
+ let next_centroid = if reverse {
+ *digest.buffer.centroids.last().unwrap()
+ } else {
+ digest.buffer.centroids[0]
+ };
+ heap.push(MergeInput {
+ next_centroid,
+ centroids: digest.buffer.centroids.into_iter(),
+ unmerged_len,
+ input_index,
+ });
+ }
+
+ if !all_compressed {
+ // Raw tails precede compressed prefixes to preserve stable
equal-mean ordering.
+ let mut centroids = Vec::with_capacity(num_centroids);
+ for input in &heap {
+ let prefix_len = input.centroids.len() - input.unmerged_len;
+
centroids.extend_from_slice(&input.centroids.as_slice()[prefix_len..]);
+ }
+ for input in heap {
+ let prefix_len = input.centroids.len() - input.unmerged_len;
+ centroids.extend(input.centroids.take(prefix_len));
+ }
+ centroids.sort_by(centroid_cmp);
+ merged.compress_sorted_centroids(centroids, total_weight);
+ return merged;
+ }
+
+ for index in (0..heap.len() / 2).rev() {
+ sift_down_merge_heap(&mut heap, index, reverse);
+ }
+ let compressed_weight = total_weight as f64;
+ let normalizer = scale_function::normalizer(2.0 * f64::from(merged.k),
compressed_weight);
+ // Reserve compressed output only; later updates can grow their own
workspace.
+ let mut retained: Vec<Centroid> =
+ Vec::with_capacity(merged.target_centroids().min(num_centroids));
+ let mut weight_so_far = 0.;
+ // Advancing the selected input can only move it down the heap; the
other inputs stay put.
+ for current in 0..num_centroids {
+ let input = &mut heap[0];
+ let centroid = input.next_centroid;
+ let _ = if reverse {
+ input.centroids.next_back()
+ } else {
+ input.centroids.next()
+ };
+ if input.centroids.len() == 0 {
+ heap.swap_remove(0);
+ } else {
+ input.next_centroid = if reverse {
+ *input.centroids.as_slice().last().unwrap()
+ } else {
+ input.centroids.as_slice()[0]
+ };
+ }
+ sift_down_merge_heap(&mut heap, 0, reverse);
+
+ if let Some(last) = retained.last_mut() {
+ let proposed_weight = last.weight() + centroid.weight();
+ if should_merge_centroid(
+ current,
+ num_centroids,
+ weight_so_far,
+ proposed_weight,
+ compressed_weight,
+ normalizer,
+ ) {
+ last.add(centroid);
+ } else {
+ weight_so_far += last.weight();
+ retained.push(centroid);
+ }
+ } else {
+ retained.push(centroid);
+ }
+ }
+
+ debug_assert!(retained.len() <= merged.target_centroids());
+ if reverse {
+ retained.reverse();
+ }
+ debug_assert_eq!(
+ retained
+ .iter()
+ .map(|centroid| centroid.weight.get())
+ .sum::<u64>(),
+ total_weight,
+ "compressed centroids must preserve total weight"
+ );
+ merged.compressed_weight = total_weight;
+ merged.reverse_merge = !reverse;
+ merged.buffer = TDigestBuffer::new(retained, 0);
+ merged
+ }
+}
+
fn serialize_compressed(
k: u16,
reverse_merge: bool,
@@ -1667,6 +1904,42 @@ fn centroids_are_sorted(centroids: &[Centroid]) -> bool {
.all(|pair| centroid_cmp(&pair[0], &pair[1]) != Ordering::Greater)
}
+/// Whether the last output centroid can absorb the next input under the K_2
weight limit.
+///
+/// `input_index` (zero-based) and `input_len` count input centroids in scan
order.
+/// `weight_before` sums the finalized output weights, excluding the last
output centroid;
+/// `proposed_weight` combines that centroid and the next input.
`total_weight` sums all
+/// input weights, and `normalizer` is the K_2 normalizer for compression `2 *
sketch.k`.
+///
+/// The [paper] limits a centroid with weight > 1 to a scale span of at most
one (Section 2.3,
+/// Eq. 4). As in the [reference implementation], we use a conservative weight
limit instead of
+/// evaluating that span: K_2's reciprocal slope, `q * (1 - q) / normalizer`,
bounds the
+/// allowed fraction of total weight. Both ends of the proposed rank interval
must allow
+/// the merge, so we take the smaller limit. This keeps centroids finer near
the tails;
+/// symmetry under `q -> 1 - q` also permits scanning in reverse. See Section
2.8, Eq. 8 for K_2.
+///
+/// [paper]: https://arxiv.org/abs/1902.04023
+/// [reference implementation]:
https://github.com/tdunning/t-digest/blob/eca1125a39c13e918d7054105dc89bcda11cfa44/core/src/main/java/com/tdunning/math/stats/MergingDigest.java#L421-L436
+fn should_merge_centroid(
+ input_index: usize,
+ input_len: usize,
+ weight_before: f64,
+ proposed_weight: f64,
+ total_weight: f64,
+ normalizer: f64,
+) -> bool {
+ // Index 0 already seeded the output: refusing index 1 and the last index
preserves
+ // the first and last input centroids without further merging.
+ if input_index == 1 || input_index == input_len - 1 {
+ return false;
+ }
+ let q_start = weight_before / total_weight;
+ let q_end = (weight_before + proposed_weight) / total_weight;
+ let weight_limit = total_weight
+ * scale_function::max(q_start,
normalizer).min(scale_function::max(q_end, normalizer));
+ proposed_weight <= weight_limit
+}
+
fn merge_sorted_centroids(left: &mut Vec<Centroid>, right: &[Centroid]) {
debug_assert!(!right.is_empty());
debug_assert!(centroids_are_sorted(left));
diff --git a/tests-integration/tests/tdigest_test/sketch.rs
b/tests-integration/tests/tdigest_test/sketch.rs
index e35d2d6..cae816b 100644
--- a/tests-integration/tests/tdigest_test/sketch.rs
+++ b/tests-integration/tests/tdigest_test/sketch.rs
@@ -287,6 +287,246 @@ fn test_merge_large() {
assert_that!(td1.rank(n as f64).unwrap(), eq(1.0));
}
+#[test]
+fn test_mixed_k_merge_retains_receiver_k() {
+ for (left_k, right_k, right_count) in [(200, 50, 1_000), (50, 200, 1_000),
(200, 10, 1)] {
+ let mut left = TDigestMut::new(left_k).unwrap();
+ let mut right = TDigestMut::new(right_k).unwrap();
+ for value in 0..10_000 {
+ left.update(value as f64);
+ }
+ for value in 0..right_count {
+ right.update((value + 10_000) as f64);
+ }
+
+ left.merge(&right);
+
+ assert_eq!(left.k(), left_k);
+ assert_eq!(left.total_weight(), 10_000 + right_count);
+ assert_eq!(left.quantile(0.0), Some(0.0));
+ assert_eq!(left.quantile(1.0), Some((9_999 + right_count) as f64));
+ }
+}
+
+#[test]
+fn test_from_iter_uses_one_result_with_the_smallest_nonempty_k() {
+ let mut first = TDigestMut::new(100).unwrap();
+ let mut second = TDigestMut::new(50).unwrap();
+ let empty = TDigestMut::new(10).unwrap();
+ for value in 0..1_000 {
+ first.update(value as f64);
+ second.update((value + 1_000) as f64);
+ }
+ let _ = first.quantile(0.5);
+ let _ = second.quantile(0.5);
+
+ let mut merged = [first, empty,
second].into_iter().collect::<TDigestMut>();
+
+ assert_eq!(merged.k(), 50);
+ assert_eq!(merged.total_weight(), 2_000);
+ assert_eq!(merged.min_value(), Some(0.0));
+ assert_eq!(merged.max_value(), Some(1_999.0));
+ let quantiles = (0..=100)
+ .map(|rank| merged.quantile(rank as f64 / 100.).unwrap())
+ .collect::<Vec<_>>();
+ assert!(quantiles.windows(2).all(|pair| pair[0] <= pair[1]));
+}
+
+#[test]
+fn test_from_iter_matches_single_digest_for_uncompressed_inputs() {
+ let values = [3.0, 1.0, 2.0, 2.0, 5.0, 4.0];
+ let partials = values
+ .chunks(3)
+ .map(|values| {
+ let mut digest = TDigestMut::new(100).unwrap();
+ for &value in values {
+ digest.update(value);
+ }
+ digest
+ })
+ .collect::<Vec<_>>();
+ let mut merged = partials.into_iter().collect::<TDigestMut>();
+
+ let mut expected = TDigestMut::new(100).unwrap();
+ for value in values {
+ expected.update(value);
+ }
+
+ assert_eq!(merged.serialize(), expected.serialize());
+}
+
+#[test]
+fn test_from_iter_matches_single_compression_for_interleaved_runs() {
+ let runs: [&[(f64, u64)]; 4] = [
+ &[(0.0, 1), (3.0, 2), (3.0, 5), (6.0, 100), (9.0, 1)],
+ &[(0.0, 7), (2.0, 3), (5.0, 4), (8.0, 2), (9.0, 10)],
+ &[(1.0, 2), (3.0, 6), (4.0, 1), (7.0, 8), (9.0, 20)],
+ &[(0.0, 2), (3.0, 9), (6.0, 7), (9.0, 30)],
+ ];
+ for k in [10, 100] {
+ for reverse in [false, true] {
+ let make_digest = |centroids: &[(f64, u64)]| {
+ let mut digest = deserialize_with_centroids(k, -10.0, 20.0,
centroids);
+ if reverse {
+ let mut bytes = digest.serialize();
+ bytes[5] |= 1 << 2; // reverse-merge flag
+ digest = TDigestMut::deserialize(&bytes).unwrap();
+ }
+ digest
+ };
+ let mut ordered = runs
+ .iter()
+ .flat_map(|run| run.iter().copied())
+ .collect::<Vec<_>>();
+ ordered.sort_by(|left, right| left.0.total_cmp(&right.0));
+
+ // Borrowed merge puts the right input first on ties. Keeping the
last centroid on
+ // the left reproduces the concatenated runs' stable order in one
compression pass.
+ let last = ordered.pop().unwrap();
+ let mut expected = make_digest(&[last]);
+ expected.merge(&make_digest(&ordered));
+ let mut merged =
runs.into_iter().map(make_digest).collect::<TDigestMut>();
+
+ assert_eq!(merged.serialize(), expected.serialize());
+ assert_eq!(merged.quantile(0.0), Some(-10.0));
+ assert_eq!(merged.quantile(1.0), Some(20.0));
+
+ // A collected digest must still support enough updates to grow
and compress again.
+ for value in 0..1_000 {
+ merged.update(value as f64);
+ expected.update(value as f64);
+ }
+ assert_eq!(merged.serialize(), expected.serialize());
+ }
+ }
+}
+
+#[test]
+fn test_from_iter_handles_mixed_compressed_and_buffered_inputs() {
+ let mut expected = TDigestMut::new(100).unwrap();
+ let partials = (0..4)
+ .map(|run| {
+ let mut digest = TDigestMut::new(100).unwrap();
+ for row in 0..60 {
+ let value = ((row * 13 + run * 7) % 37) as f64;
+ digest.update(value);
+ expected.update(value);
+ if run == 2 && row == 29 {
+ let _ = digest.quantile(0.5);
+ }
+ }
+ if run == 1 {
+ let _ = digest.quantile(0.5);
+ }
+ digest
+ })
+ .collect::<Vec<_>>();
+ let mut merged = partials.into_iter().collect::<TDigestMut>();
+
+ assert_eq!(merged.total_weight(), 240);
+ assert_eq!(merged.serialize(), expected.serialize());
+}
+
+#[test]
+fn test_serialized_batch_merge_tree_preserves_weight_and_quantiles() {
+ let mut values = Vec::new();
+ let partials = (0..64)
+ .map(|input| {
+ let mut digest = TDigestMut::default();
+ // Unequal weights, empty states, and repeated values exercise
intermediate merges.
+ let rows = [0, 1, 8, 64, 1_024][input % 5];
+ for row in 0..rows {
+ let value = ((row * 37 + input * 113) % 1_024) as f64 - 512.0;
+ values.push(value);
+ digest.update(value);
+ }
+ digest.serialize()
+ })
+ .collect::<Vec<_>>();
+ values.sort_by(f64::total_cmp);
+
+ for fan_in in [2, 7, 64] {
+ let mut states = partials.clone();
+ while states.len() > 1 {
+ states = states
+ .chunks(fan_in)
+ .map(|batch| {
+ let mut merged = batch
+ .iter()
+ .map(|bytes| TDigestMut::deserialize(bytes))
+ .collect::<Result<TDigestMut, _>>()
+ .unwrap();
+ merged.serialize()
+ })
+ .collect();
+ }
+
+ let mut merged = TDigestMut::deserialize(&states[0]).unwrap();
+ assert_eq!(merged.total_weight(), values.len() as u64);
+ assert_eq!(merged.quantile(0.0), values.first().copied());
+ assert_eq!(merged.quantile(1.0), values.last().copied());
+
+ let mut previous = values[0];
+ for rank in [0.01, 0.5, 0.9, 0.99] {
+ let estimate = merged.quantile(rank).unwrap();
+ assert!((previous..=values[values.len() - 1]).contains(&estimate));
+ // Check this fixture's empirical rank, allowing equal values to
span a rank interval.
+ let lower =
+ values.partition_point(|value| *value < estimate) as f64 /
values.len() as f64;
+ let upper =
+ values.partition_point(|value| *value <= estimate) as f64 /
values.len() as f64;
+ assert!(
+ (lower - 0.01..=upper + 0.01).contains(&rank),
+ "fan_in={fan_in}, rank={rank}, estimate={estimate},
observed=[{lower}, {upper}]"
+ );
+ previous = estimate;
+ }
+ }
+}
+
+#[test]
+fn test_from_iter_checks_total_weight_before_compression() {
+ for compressed in [false, true] {
+ let heavy = deserialize_with_centroids(100, 0.0, 0.0, &[(0.0, u64::MAX
- 2)]);
+ let [one, two, extra] = [1.0, 2.0, 3.0].map(|value| {
+ let mut digest = TDigestMut::new(100).unwrap();
+ digest.update(value);
+ if compressed {
+ let _ = digest.quantile(0.5);
+ }
+ digest
+ });
+ let mut at_limit = [heavy.clone(), one.clone(), two.clone()]
+ .into_iter()
+ .collect::<TDigestMut>();
+ assert_eq!(at_limit.total_weight(), u64::MAX);
+ assert_eq!(at_limit.quantile(0.0), Some(0.0));
+ assert_eq!(at_limit.quantile(1.0), Some(2.0));
+
+ assert!(
+ catch_unwind(|| [heavy, one, two,
extra].into_iter().collect::<TDigestMut>()).is_err()
+ );
+ }
+}
+
+#[test]
+fn test_from_iter_handles_empty_and_single_input_without_recompression() {
+ let empty = std::iter::empty::<TDigestMut>().collect::<TDigestMut>();
+ assert!(empty.is_empty());
+
+ let mut input = TDigestMut::new(50).unwrap();
+ for value in 0..1_000 {
+ input.update(value as f64);
+ }
+ let serialized = input.serialize();
+ let mut collected = [TDigestMut::new(10).unwrap(), input]
+ .into_iter()
+ .collect::<TDigestMut>();
+
+ assert_eq!(collected.k(), 50);
+ assert_eq!(collected.serialize(), serialized);
+}
+
#[test]
fn test_invalid_inputs() {
let n = 100;
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]