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 0cf3cf6  refactor(tdigest): organize public APIs and internal helpers 
(#313)
0cf3cf6 is described below

commit 0cf3cf6a8ca0d55c9a2d25197fcadb56976223a2
Author: tison <[email protected]>
AuthorDate: Wed Oct 28 00:28:06 2026 +0800

    refactor(tdigest): organize public APIs and internal helpers (#313)
---
 datasketches/src/tdigest/mod.rs           |    2 -
 datasketches/src/tdigest/serialization.rs |   27 -
 datasketches/src/tdigest/sketch.rs        | 2703 +++++++++++++++--------------
 3 files changed, 1355 insertions(+), 1377 deletions(-)

diff --git a/datasketches/src/tdigest/mod.rs b/datasketches/src/tdigest/mod.rs
index 8c5818b..652ba81 100644
--- a/datasketches/src/tdigest/mod.rs
+++ b/datasketches/src/tdigest/mod.rs
@@ -65,8 +65,6 @@
 //! # Ok::<(), datasketches::error::Error>(())
 //! ```
 
-mod serialization;
-
 mod sketch;
 pub use self::sketch::TDigest;
 pub use self::sketch::TDigestMut;
diff --git a/datasketches/src/tdigest/serialization.rs 
b/datasketches/src/tdigest/serialization.rs
deleted file mode 100644
index debe678..0000000
--- a/datasketches/src/tdigest/serialization.rs
+++ /dev/null
@@ -1,27 +0,0 @@
-// 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.
-
-pub const PREAMBLE_LONGS_EMPTY_OR_SINGLE: u8 = 1;
-pub const PREAMBLE_LONGS_MULTIPLE: u8 = 2;
-pub const SERIAL_VERSION: u8 = 1;
-pub const FLAGS_IS_EMPTY: u8 = 1 << 0;
-pub const FLAGS_IS_SINGLE_VALUE: u8 = 1 << 1;
-pub const FLAGS_REVERSE_MERGE: u8 = 1 << 2;
-/// the format of the reference implementation is using double (f64) precision
-pub const COMPAT_DOUBLE: u32 = 1;
-/// the format of the reference implementation is using float (f32) precision
-pub const COMPAT_FLOAT: u32 = 2;
diff --git a/datasketches/src/tdigest/sketch.rs 
b/datasketches/src/tdigest/sketch.rs
index 83d8d2d..e665a10 100644
--- a/datasketches/src/tdigest/sketch.rs
+++ b/datasketches/src/tdigest/sketch.rs
@@ -25,14 +25,6 @@ use crate::codec::assert::ensure_serial_version_is;
 use crate::codec::assert::insufficient_data;
 use crate::codec::family::Family;
 use crate::error::Error;
-use crate::tdigest::serialization::COMPAT_DOUBLE;
-use crate::tdigest::serialization::COMPAT_FLOAT;
-use crate::tdigest::serialization::FLAGS_IS_EMPTY;
-use crate::tdigest::serialization::FLAGS_IS_SINGLE_VALUE;
-use crate::tdigest::serialization::FLAGS_REVERSE_MERGE;
-use crate::tdigest::serialization::PREAMBLE_LONGS_EMPTY_OR_SINGLE;
-use crate::tdigest::serialization::PREAMBLE_LONGS_MULTIPLE;
-use crate::tdigest::serialization::SERIAL_VERSION;
 
 /// The default value of K if one is not specified.
 const DEFAULT_K: u16 = 200;
@@ -43,126 +35,6 @@ const INITIAL_UNMERGED_CAPACITY: usize = 8;
 /// Default weight for single values.
 const DEFAULT_WEIGHT: NonZeroU64 = NonZeroU64::new(1).unwrap();
 
-// Centroids are stored as `[compressed prefix | unmerged unit-weight tail]`. 
Carrying a unit weight
-// for each unmerged value lets updates, compression, and merge reuse one 
allocation instead of
-// converting raw values into a second vector during compression.
-#[derive(Debug, Clone, Default)]
-struct TDigestBuffer {
-    centroids: Vec<Centroid>,
-    unmerged_tail_len: usize,
-}
-
-impl TDigestBuffer {
-    fn new(centroids: Vec<Centroid>, unmerged_tail_len: usize) -> Self {
-        debug_assert!(unmerged_tail_len <= centroids.len());
-        TDigestBuffer {
-            centroids,
-            unmerged_tail_len,
-        }
-    }
-
-    fn len(&self) -> usize {
-        self.centroids.len()
-    }
-
-    fn is_empty(&self) -> bool {
-        self.centroids.is_empty()
-    }
-
-    fn unmerged_len(&self) -> usize {
-        self.unmerged_tail_len
-    }
-
-    fn compressed_prefix_len(&self) -> usize {
-        self.centroids.len() - self.unmerged_tail_len
-    }
-
-    fn push_unmerged(&mut self, value: f64, max_unmerged: usize) {
-        debug_assert!(self.unmerged_tail_len < max_unmerged);
-        if self.centroids.len() == self.centroids.capacity() {
-            let target_unmerged = if self.unmerged_tail_len == 0 {
-                INITIAL_UNMERGED_CAPACITY
-            } else if self.unmerged_tail_len == INITIAL_UNMERGED_CAPACITY {
-                // Once a digest outgrows a tiny group, skip an extra 
allocator round trip while
-                // keeping the first allocation small.
-                (INITIAL_UNMERGED_CAPACITY * UNMERGED_MULTIPLIER * 
UNMERGED_MULTIPLIER)
-                    .min(max_unmerged)
-            } else {
-                self.unmerged_tail_len
-                    .saturating_mul(UNMERGED_MULTIPLIER)
-                    .min(max_unmerged)
-            };
-            let target_capacity = 
self.compressed_prefix_len().saturating_add(target_unmerged);
-            self.centroids
-                
.reserve_exact(target_capacity.saturating_sub(self.centroids.len()));
-        }
-
-        self.centroids.push(Centroid {
-            mean: value,
-            weight: DEFAULT_WEIGHT,
-        });
-        self.unmerged_tail_len += 1;
-    }
-
-    /// Returns all centroids in the tie order expected by stable compression 
sorting.
-    ///
-    /// The buffer is rotated from `[compressed | unmerged]` to `[unmerged | 
compressed]`, so new
-    /// values stay before existing centroids when their means are equal.
-    fn into_centroids_for_compression(mut self) -> Vec<Centroid> {
-        debug_assert_ne!(self.unmerged_tail_len, 0);
-        let compressed_prefix_len = self.compressed_prefix_len();
-        self.centroids.rotate_left(compressed_prefix_len);
-        self.centroids
-    }
-
-    /// 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 {
-            // 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;
-        }
-
-        let compressed_prefix_len = self.compressed_prefix_len();
-        self.centroids.reserve(other.len());
-        let other_prefix_len = other.compressed_prefix_len();
-        self.centroids
-            .extend_from_slice(&other.centroids[other_prefix_len..]);
-        self.centroids
-            .extend_from_slice(&other.centroids[..other_prefix_len]);
-        // Preserve the stable tie order: left unmerged, right unmerged and 
compressed, then the
-        // left compressed prefix.
-        self.centroids.rotate_left(compressed_prefix_len);
-        self.centroids.sort_by(centroid_cmp);
-        self.centroids
-    }
-
-    #[inline]
-    fn compressed_centroids(&self) -> &[Centroid] {
-        assert_eq!(
-            self.unmerged_tail_len, 0,
-            "t-digest buffer must be compressed before reading centroids"
-        );
-        &self.centroids
-    }
-
-    fn into_compressed_centroids(self) -> Vec<Centroid> {
-        assert_eq!(
-            self.unmerged_tail_len, 0,
-            "t-digest buffer must be compressed before reading centroids"
-        );
-        self.centroids
-    }
-
-    /// Returns an estimate of memory usage in bytes.
-    fn estimated_size(&self) -> usize {
-        self.centroids.capacity() * size_of::<Centroid>()
-    }
-}
-
 /// T-Digest sketch for estimating quantiles and ranks.
 ///
 /// See the [module level documentation](super) for more.
@@ -182,7 +54,7 @@ pub struct TDigestMut {
 
 impl Default for TDigestMut {
     fn default() -> Self {
-        Self::make(
+        Self::from_parts(
             DEFAULT_K,
             false,
             f64::INFINITY,
@@ -215,7 +87,7 @@ impl TDigestMut {
             )));
         }
 
-        Ok(Self::make(
+        Ok(Self::from_parts(
             k,
             false,
             f64::INFINITY,
@@ -225,41 +97,6 @@ impl TDigestMut {
         ))
     }
 
-    fn make(
-        k: u16,
-        reverse_merge: bool,
-        min: f64,
-        max: f64,
-        buffer: TDigestBuffer,
-        compressed_weight: u64,
-    ) -> Self {
-        debug_assert!(k >= 10, "k must be at least 10");
-        debug_assert!(buffer.unmerged_tail_len <= buffer.centroids.len());
-        debug_assert!(buffer.compressed_prefix_len() != 0 || compressed_weight 
== 0);
-
-        TDigestMut {
-            k,
-            reverse_merge,
-            min,
-            max,
-            buffer,
-            compressed_weight,
-        }
-    }
-
-    fn target_centroids(&self) -> usize {
-        let fudge = if self.k < 30 { 30 } else { 10 };
-        (usize::from(self.k) * 2) + fudge
-    }
-
-    fn max_unmerged(&self) -> usize {
-        self.target_centroids() * UNMERGED_MULTIPLIER
-    }
-
-    fn target_retained_capacity(&self) -> usize {
-        self.target_centroids() + self.max_unmerged()
-    }
-
     /// Updates this t-digest with the given value.
     ///
     /// [f64::NAN], [f64::INFINITY], and [f64::NEG_INFINITY] values are 
ignored.
@@ -393,17 +230,6 @@ impl TDigestMut {
         }
     }
 
-    #[inline]
-    fn view(&mut self) -> TDigestView<'_> {
-        self.compress(); // side effect
-        TDigestView {
-            min: self.min,
-            max: self.max,
-            centroids: self.buffer.compressed_centroids(),
-            centroids_weight: self.compressed_weight,
-        }
-    }
-
     /// Returns the cumulative distribution approximation described by 
[`TDigest::cdf`].
     ///
     /// Returns `Ok(None)` if this t-digest is empty.
@@ -629,310 +455,55 @@ impl TDigestMut {
         Self::deserialize_impl(bytes, true)
     }
 
-    fn deserialize_impl(bytes: &[u8], is_f32: bool) -> Result<Self, Error> {
-        let mut cursor = SketchSlice::new(bytes);
+    /// Returns an estimate of the sketch's memory usage in bytes.
+    pub fn estimated_size(&self) -> usize {
+        self.buffer.estimated_size()
+    }
 
-        let preamble_longs = cursor
-            .read_u8()
-            .map_err(insufficient_data("preamble_longs"))?;
-        let serial_version = cursor
-            .read_u8()
-            .map_err(insufficient_data("serial_version"))?;
-        let family_id = 
cursor.read_u8().map_err(insufficient_data("family_id"))?;
-        if let Err(err) = Family::TDIGEST.validate_id(family_id) {
-            return if preamble_longs == 0 && serial_version == 0 && family_id 
== 0 {
-                Self::deserialize_compat(bytes)
-            } else {
-                Err(err)
-            };
-        }
-        ensure_serial_version_is(SERIAL_VERSION, serial_version)?;
-        let k = cursor.read_u16_le().map_err(insufficient_data("k"))?;
-        if k < 10 {
-            return Err(Error::deserial(format!("k must be at least 10, got 
{k}")));
-        }
-        let flags = cursor.read_u8().map_err(insufficient_data("flags"))?;
-        let known_flags = FLAGS_IS_EMPTY | FLAGS_IS_SINGLE_VALUE | 
FLAGS_REVERSE_MERGE;
-        if flags & !known_flags != 0 {
-            return Err(Error::deserial(format!(
-                "malformed data: unknown TDigest flags 0x{:02x}",
-                flags & !known_flags
-            )));
-        }
-        let is_empty = (flags & FLAGS_IS_EMPTY) != 0;
-        let is_single_value = (flags & FLAGS_IS_SINGLE_VALUE) != 0;
-        if is_empty && is_single_value {
-            return Err(Error::deserial(
-                "malformed data: empty and single-value flags are mutually 
exclusive",
-            ));
+    fn from_parts(
+        k: u16,
+        reverse_merge: bool,
+        min: f64,
+        max: f64,
+        buffer: TDigestBuffer,
+        compressed_weight: u64,
+    ) -> Self {
+        debug_assert!(k >= 10, "k must be at least 10");
+        debug_assert!(buffer.unmerged_tail_len <= buffer.centroids.len());
+        debug_assert!(buffer.compressed_prefix_len() != 0 || compressed_weight 
== 0);
+
+        TDigestMut {
+            k,
+            reverse_merge,
+            min,
+            max,
+            buffer,
+            compressed_weight,
         }
-        let expected_preamble_longs = if is_empty || is_single_value {
-            PREAMBLE_LONGS_EMPTY_OR_SINGLE
-        } else {
-            PREAMBLE_LONGS_MULTIPLE
-        };
-        ensure_preamble_longs_in(&[expected_preamble_longs], preamble_longs)?;
-        cursor
-            .read_u16_le()
-            .map_err(insufficient_data("<unused>"))?; // unused
-        if is_empty {
-            return TDigestMut::new(k);
+    }
+
+    #[inline]
+    fn view(&mut self) -> TDigestView<'_> {
+        self.compress(); // side effect
+        TDigestView {
+            min: self.min,
+            max: self.max,
+            centroids: self.buffer.compressed_centroids(),
+            centroids_weight: self.compressed_weight,
         }
+    }
 
-        let reverse_merge = (flags & FLAGS_REVERSE_MERGE) != 0;
-        if is_single_value {
-            let value = if is_f32 {
-                cursor
-                    .read_f32_le()
-                    .map_err(insufficient_data("single_value"))? as f64
-            } else {
-                cursor
-                    .read_f64_le()
-                    .map_err(insufficient_data("single_value"))?
-            };
-            check_non_nan(value, "single_value")?;
-            check_finite(value, "single_value")?;
-            return Ok(TDigestMut::make(
-                k,
-                reverse_merge,
-                value,
-                value,
-                TDigestBuffer::new(
-                    vec![Centroid {
-                        mean: value,
-                        weight: DEFAULT_WEIGHT,
-                    }],
-                    0,
-                ),
-                1,
-            ));
-        }
-        let num_centroids = cursor
-            .read_u32_le()
-            .map_err(insufficient_data("num_centroids"))? as usize;
-        let num_buffered = cursor
-            .read_u32_le()
-            .map_err(insufficient_data("num_buffered"))? as usize;
-        let (min, max) = if is_f32 {
-            (
-                cursor.read_f32_le().map_err(insufficient_data("min"))? as f64,
-                cursor.read_f32_le().map_err(insufficient_data("max"))? as f64,
-            )
-        } else {
-            (
-                cursor.read_f64_le().map_err(insufficient_data("min"))?,
-                cursor.read_f64_le().map_err(insufficient_data("max"))?,
-            )
-        };
-        check_extrema(min, max, "TDigest")?;
-        let (centroid_bytes, buffered_value_bytes) = if is_f32 {
-            (size_of::<f32>() + size_of::<u32>(), size_of::<f32>())
-        } else {
-            (size_of::<f64>() + size_of::<u64>(), size_of::<f64>())
-        };
-        let centroid_payload_bytes = num_centroids
-            .checked_mul(centroid_bytes)
-            .ok_or_else(|| Error::deserial("TDigest payload size exceeds the 
supported size"))?;
-        let buffered_payload_bytes = num_buffered
-            .checked_mul(buffered_value_bytes)
-            .ok_or_else(|| Error::deserial("TDigest payload size exceeds the 
supported size"))?;
-        let required_payload_bytes = centroid_payload_bytes
-            .checked_add(buffered_payload_bytes)
-            .ok_or_else(|| Error::deserial("TDigest payload size exceeds the 
supported size"))?;
-        // Check the whole payload once so fixed-width records can be decoded 
without per-field I/O.
-        let available_bytes = cursor.remaining().len();
-        if available_bytes < required_payload_bytes {
-            return Err(Error::insufficient_data_of(
-                "TDigest payload",
-                format_args!("expected {required_payload_bytes} bytes, got 
{available_bytes}"),
-            ));
-        }
-        let payload = &cursor.remaining()[..required_payload_bytes];
-        let (centroid_payload, buffered_payload) = 
payload.split_at(centroid_payload_bytes);
-        let stored_centroids = 
num_centroids.checked_add(num_buffered).ok_or_else(|| {
-            Error::deserial("num_centroids and num_buffered exceed the 
supported size")
-        })?;
-        if stored_centroids == 0 {
-            return Err(Error::deserial(
-                "malformed data: non-empty TDigest must contain a centroid or 
buffered value",
-            ));
-        }
-        let mut centroids = Vec::with_capacity(stored_centroids);
-        let mut compressed_weight = 0u64;
-        let mut previous_mean = min;
-        let mut centroid_means_valid = true;
-        for bytes in centroid_payload.chunks_exact(centroid_bytes) {
-            let (mean, weight) = if is_f32 {
-                (
-                    f32::from_le_bytes(bytes[..4].try_into().unwrap()) as f64,
-                    u32::from_le_bytes(bytes[4..].try_into().unwrap()) as u64,
-                )
-            } else {
-                (
-                    f64::from_le_bytes(bytes[..8].try_into().unwrap()),
-                    u64::from_le_bytes(bytes[8..].try_into().unwrap()),
-                )
-            };
-            centroid_means_valid &= mean.is_finite() & (mean >= previous_mean) 
& (mean <= max);
-            previous_mean = mean;
-            let weight = check_nonzero(weight, "centroid weight")?;
-            compressed_weight = checked_weight_sum(compressed_weight, 
weight.get())?;
-            centroids.push(Centroid { mean, weight });
-        }
-        if !centroid_means_valid {
-            return Err(Error::deserial(
-                "malformed data: centroid means must be finite, within 
extrema, and nondecreasing",
-            ));
-        }
-        let total_weight = checked_weight_sum(compressed_weight, num_buffered 
as u64)?;
-        check_single_sample_extrema(min, max, total_weight)?;
-        let mut buffered_values_valid = true;
-        for bytes in buffered_payload.chunks_exact(buffered_value_bytes) {
-            let value = if is_f32 {
-                f32::from_le_bytes(bytes.try_into().unwrap()) as f64
-            } else {
-                f64::from_le_bytes(bytes.try_into().unwrap())
-            };
-            buffered_values_valid &= value.is_finite() & (value >= min) & 
(value <= max);
-            centroids.push(Centroid {
-                mean: value,
-                weight: DEFAULT_WEIGHT,
-            });
-        }
-        if !buffered_values_valid {
-            return Err(Error::deserial(
-                "malformed data: buffered values must be finite and within 
extrema",
-            ));
-        }
-        Ok(TDigestMut::make(
-            k,
-            reverse_merge,
-            min,
-            max,
-            TDigestBuffer::new(centroids, num_buffered),
-            compressed_weight,
-        ))
+    fn target_centroids(&self) -> usize {
+        let fudge = if self.k < 30 { 30 } else { 10 };
+        (usize::from(self.k) * 2) + fudge
     }
 
-    // compatibility with the format of the reference implementation
-    // default byte order of ByteBuffer is used there, which is big endian
-    fn deserialize_compat(bytes: &[u8]) -> Result<Self, Error> {
-        fn make_error(tag: &'static str) -> impl FnOnce(std::io::Error) -> 
Error {
-            move |_| Error::insufficient_data_of("compat format", tag)
-        }
-
-        let mut cursor = SketchSlice::new(bytes);
+    fn max_unmerged(&self) -> usize {
+        self.target_centroids() * UNMERGED_MULTIPLIER
+    }
 
-        let ty = cursor.read_u32_be().map_err(make_error("type"))?;
-        match ty {
-            COMPAT_DOUBLE => {
-                fn make_error(tag: &'static str) -> impl 
FnOnce(std::io::Error) -> Error {
-                    move |_| Error::insufficient_data_of("compat double 
format", tag)
-                }
-                // compatibility with asBytes()
-                let min = cursor.read_f64_be().map_err(make_error("min"))?;
-                let max = cursor.read_f64_be().map_err(make_error("max"))?;
-                check_extrema(min, max, "compat double TDigest")?;
-                let k = cursor.read_f64_be().map_err(make_error("k"))? as u16;
-                if k < 10 {
-                    return Err(Error::deserial(format!(
-                        "k must be at least 10 in compat double format, got 
{k}"
-                    )));
-                }
-                let num_centroids =
-                    cursor.read_u32_be().map_err(make_error("num_centroids"))? 
as usize;
-                if num_centroids == 0 {
-                    return Err(Error::deserial(
-                        "malformed data: compat double TDigest must contain a 
centroid",
-                    ));
-                }
-                let mut total_weight = 0u64;
-                let mut centroids = Vec::with_capacity(num_centroids);
-                let mut previous_mean = min;
-                let mut centroid_means_valid = true;
-                for _ in 0..num_centroids {
-                    let weight = 
cursor.read_f64_be().map_err(make_error("weight"))?;
-                    let mean = 
cursor.read_f64_be().map_err(make_error("mean"))?;
-                    let weight =
-                        check_compat_weight(weight, "centroid weight in compat 
double format")?;
-                    centroid_means_valid &=
-                        mean.is_finite() & (mean >= previous_mean) & (mean <= 
max);
-                    previous_mean = mean;
-                    total_weight = checked_weight_sum(total_weight, 
weight.get())?;
-                    centroids.push(Centroid { mean, weight });
-                }
-                if !centroid_means_valid {
-                    return Err(Error::deserial(
-                        "malformed data: centroid means in compat double 
format must be finite, within extrema, and nondecreasing",
-                    ));
-                }
-                check_single_sample_extrema(min, max, total_weight)?;
-                Ok(TDigestMut::make(
-                    k,
-                    false,
-                    min,
-                    max,
-                    TDigestBuffer::new(centroids, 0),
-                    total_weight,
-                ))
-            }
-            COMPAT_FLOAT => {
-                fn make_error(tag: &'static str) -> impl 
FnOnce(std::io::Error) -> Error {
-                    move |_| Error::insufficient_data_of("compat float 
format", tag)
-                }
-                // COMPAT_FLOAT: compatibility with asSmallBytes()
-                // reference implementation uses doubles for min and max
-                let min = cursor.read_f64_be().map_err(make_error("min"))?;
-                let max = cursor.read_f64_be().map_err(make_error("max"))?;
-                check_extrema(min, max, "compat float TDigest")?;
-                let k = cursor.read_f32_be().map_err(make_error("k"))? as u16;
-                if k < 10 {
-                    return Err(Error::deserial(format!(
-                        "k must be at least 10 in compat float format, got {k}"
-                    )));
-                }
-                // reference implementation stores capacities of the array of 
centroids and the
-                // buffer as shorts they can be derived from k in the 
constructor
-                cursor.read_u32_be().map_err(make_error("<unused>"))?;
-                let num_centroids =
-                    cursor.read_u16_be().map_err(make_error("num_centroids"))? 
as usize;
-                if num_centroids == 0 {
-                    return Err(Error::deserial(
-                        "malformed data: compat float TDigest must contain a 
centroid",
-                    ));
-                }
-                let mut total_weight = 0u64;
-                let mut centroids = Vec::with_capacity(num_centroids);
-                let mut previous_mean = min;
-                let mut centroid_means_valid = true;
-                for _ in 0..num_centroids {
-                    let weight = 
cursor.read_f32_be().map_err(make_error("weight"))? as f64;
-                    let mean = 
cursor.read_f32_be().map_err(make_error("mean"))? as f64;
-                    let weight =
-                        check_compat_weight(weight, "centroid weight in compat 
float format")?;
-                    centroid_means_valid &=
-                        mean.is_finite() & (mean >= previous_mean) & (mean <= 
max);
-                    previous_mean = mean;
-                    total_weight = checked_weight_sum(total_weight, 
weight.get())?;
-                    centroids.push(Centroid { mean, weight });
-                }
-                if !centroid_means_valid {
-                    return Err(Error::deserial(
-                        "malformed data: centroid means in compat float format 
must be finite, within extrema, and nondecreasing",
-                    ));
-                }
-                check_single_sample_extrema(min, max, total_weight)?;
-                Ok(TDigestMut::make(
-                    k,
-                    false,
-                    min,
-                    max,
-                    TDigestBuffer::new(centroids, 0),
-                    total_weight,
-                ))
-            }
-            ty => Err(Error::deserial(format!("unknown TDigest compat type 
{ty}"))),
-        }
+    fn target_retained_capacity(&self) -> usize {
+        self.target_centroids() + self.max_unmerged()
     }
 
     /// Processes unmerged values and merges centroids if needed.
@@ -984,7 +555,7 @@ impl TDigestMut {
                     normalizer,
                 ) {
                     // merge into existing centroid
-                    centroids[num_centroids - 1].add(c);
+                    centroids[num_centroids - 1].merge(c);
                 } else {
                     // copy to a new centroid
                     weight_so_far += centroids[num_centroids - 1].weight();
@@ -1016,365 +587,54 @@ impl TDigestMut {
         // unusually large input does not pin that peak capacity for the rest 
of the digest's life.
         centroids.shrink_to(target_capacity);
     }
-
-    /// Returns an estimate of the sketch's memory usage in bytes.
-    pub fn estimated_size(&self) -> usize {
-        self.buffer.estimated_size()
-    }
 }
 
-/// 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.
+/// Immutable (frozen) T-Digest sketch for estimating quantiles and ranks.
 ///
-/// Collecting consumes each digest without cloning it. Callers that need to 
retain their inputs
-/// can use [`TDigestMut::merge`] or explicitly clone them before collection.
-///
-/// Batch collection can save repeated compression work when combining many 
small partials, but
-/// is not always faster than repeated [`TDigestMut::merge`] calls. Larger 
compressed inputs can
-/// favor incremental merging. Input count, retained centroid count, and value 
distribution all
-/// affect performance; the number of original samples alone does not 
determine the cost.
-///
-/// 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;
-        };
+/// See the [module level documentation](super) for more.
+pub struct TDigest {
+    k: u16,
 
-        // 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,
-        }
+    reverse_merge: bool,
+    min: f64,
+    max: f64,
 
-        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;
-            }
-        }
+    centroids: Vec<Centroid>,
+    centroids_weight: u64,
+}
 
-        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,
-            });
-        }
+impl TDigest {
+    /// Returns the compression parameter `k` used to configure this t-digest.
+    pub fn k(&self) -> u16 {
+        self.k
+    }
 
-        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;
-        }
+    /// Returns `true` if this t-digest has not seen any data.
+    pub fn is_empty(&self) -> bool {
+        self.centroids.is_empty()
+    }
 
-        for index in (0..heap.len() / 2).rev() {
-            sift_down_merge_heap(&mut heap, index, reverse);
+    /// Returns the minimum value seen by this t-digest, or `None` if it is 
empty.
+    pub fn min_value(&self) -> Option<f64> {
+        if self.is_empty() {
+            None
+        } else {
+            Some(self.min)
         }
-        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);
-            }
+    /// Returns the maximum value seen by this t-digest, or `None` if it is 
empty.
+    pub fn max_value(&self) -> Option<f64> {
+        if self.is_empty() {
+            None
+        } else {
+            Some(self.max)
         }
+    }
 
-        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,
-    min: f64,
-    max: f64,
-    centroids: &[Centroid],
-    total_weight: u64,
-) -> Vec<u8> {
-    let is_empty = centroids.is_empty();
-    let is_single_value = total_weight == 1;
-    let mut total_size = if is_empty || is_single_value {
-        // Preamble, serial version, family, k, flags, and two unused bytes.
-        size_of::<u64>()
-    } else {
-        // The short header plus centroid and buffered-value counts.
-        size_of::<u64>() * 2
-    };
-    if is_single_value {
-        total_size += size_of::<f64>();
-    } else if !is_empty {
-        total_size += size_of::<f64>() * 2;
-        total_size += centroids.len() * (size_of::<f64>() + size_of::<u64>());
-    }
-
-    let mut bytes = SketchBytes::with_capacity(total_size);
-    bytes.write_u8(if is_empty || is_single_value {
-        PREAMBLE_LONGS_EMPTY_OR_SINGLE
-    } else {
-        PREAMBLE_LONGS_MULTIPLE
-    });
-    bytes.write_u8(SERIAL_VERSION);
-    bytes.write_u8(Family::TDIGEST.id);
-    bytes.write_u16_le(k);
-    bytes.write_u8({
-        let mut flags = 0;
-        if is_empty {
-            flags |= FLAGS_IS_EMPTY;
-        }
-        if is_single_value {
-            flags |= FLAGS_IS_SINGLE_VALUE;
-        }
-        if reverse_merge {
-            flags |= FLAGS_REVERSE_MERGE;
-        }
-        flags
-    });
-    bytes.write_u16_le(0); // unused
-    if is_empty {
-        return bytes.into_bytes();
-    }
-    if is_single_value {
-        bytes.write_f64_le(min);
-        return bytes.into_bytes();
-    }
-    bytes.write_u32_le(centroids.len() as u32);
-    bytes.write_u32_le(0); // no buffered values
-    bytes.write_f64_le(min);
-    bytes.write_f64_le(max);
-    for centroid in centroids {
-        bytes.write_f64_le(centroid.mean);
-        bytes.write_u64_le(centroid.weight.get());
-    }
-    bytes.into_bytes()
-}
-
-/// Immutable (frozen) T-Digest sketch for estimating quantiles and ranks.
-///
-/// See the [module level documentation](super) for more.
-pub struct TDigest {
-    k: u16,
-
-    reverse_merge: bool,
-    min: f64,
-    max: f64,
-
-    centroids: Vec<Centroid>,
-    centroids_weight: u64,
-}
-
-impl TDigest {
-    /// Returns the compression parameter `k` used to configure this t-digest.
-    pub fn k(&self) -> u16 {
-        self.k
-    }
-
-    /// Returns `true` if this t-digest has not seen any data.
-    pub fn is_empty(&self) -> bool {
-        self.centroids.is_empty()
-    }
-
-    /// Returns the minimum value seen by this t-digest, or `None` if it is 
empty.
-    pub fn min_value(&self) -> Option<f64> {
-        if self.is_empty() {
-            None
-        } else {
-            Some(self.min)
-        }
-    }
-
-    /// Returns the maximum value seen by this t-digest, or `None` if it is 
empty.
-    pub fn max_value(&self) -> Option<f64> {
-        if self.is_empty() {
-            None
-        } else {
-            Some(self.max)
-        }
-    }
-
-    /// Returns the total weight.
-    pub fn total_weight(&self) -> u64 {
-        self.centroids_weight
+    /// Returns the total weight.
+    pub fn total_weight(&self) -> u64 {
+        self.centroids_weight
     }
 
     /// Serializes this immutable t-digest to bytes in double precision.
@@ -1432,15 +692,6 @@ impl TDigest {
         Ok(TDigestMut::deserialize_f32(bytes)?.freeze())
     }
 
-    fn view(&self) -> TDigestView<'_> {
-        TDigestView {
-            min: self.min,
-            max: self.max,
-            centroids: &self.centroids,
-            centroids_weight: self.centroids_weight,
-        }
-    }
-
     /// Returns an approximation to the Cumulative Distribution Function 
(CDF), which is the
     /// cumulative analog of the PMF, of the input stream given a set of split 
points.
     ///
@@ -1627,7 +878,7 @@ impl TDigest {
     /// assert_eq!(mutable.total_weight(), 2);
     /// ```
     pub fn unfreeze(self) -> TDigestMut {
-        TDigestMut::make(
+        TDigestMut::from_parts(
             self.k,
             self.reverse_merge,
             self.min,
@@ -1641,532 +892,1353 @@ impl TDigest {
     pub fn estimated_size(&self) -> usize {
         self.centroids.capacity() * size_of::<Centroid>()
     }
-}
 
-struct TDigestView<'a> {
-    min: f64,
-    max: f64,
-    centroids: &'a [Centroid],
-    centroids_weight: u64,
-}
-
-impl TDigestView<'_> {
-    fn pmf(&self, split_points: &[f64]) -> Option<Vec<f64>> {
-        let mut buckets = self.cdf(split_points)?;
-        let mut previous = buckets[0];
-        for bucket in &mut buckets[1..] {
-            let rank = *bucket;
-            *bucket -= previous;
-            previous = rank;
+    fn view(&self) -> TDigestView<'_> {
+        TDigestView {
+            min: self.min,
+            max: self.max,
+            centroids: &self.centroids,
+            centroids_weight: self.centroids_weight,
         }
-        Some(buckets)
     }
+}
 
-    fn cdf(&self, split_points: &[f64]) -> Option<Vec<f64>> {
-        if self.centroids.is_empty() {
-            return None;
-        }
-        if split_points.is_empty() {
-            return Some(vec![1.0]);
-        }
+/// 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.
+///
+/// Batch collection can save repeated compression work when combining many 
small partials, but
+/// is not always faster than repeated [`TDigestMut::merge`] calls. Larger 
compressed inputs can
+/// favor incremental merging. Input count, retained centroid count, and value 
distribution all
+/// affect performance; the number of original samples alone does not 
determine the cost.
+///
+/// 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;
+        };
 
-        let binary_search_cost = self.centroids.len().ilog2() as usize;
-        let use_scan = split_points.len().saturating_mul(binary_search_cost) > 
self.centroids.len();
-        let mut ranks = Vec::with_capacity(split_points.len() + 1);
-        if use_scan || self.min == self.max {
-            // Constant sketches and scans can fill the sorted outer tails 
directly.
-            let start = split_points.partition_point(|&p| p < self.min);
-            let end = start + split_points[start..].partition_point(|&p| p <= 
self.max);
-            ranks.resize(start, 0.0);
-            if self.min == self.max {
-                ranks.resize(end, 0.5);
-            } else {
-                self.fill_cdf_scan(&split_points[start..end], &mut ranks);
-            }
-            ranks.resize(split_points.len(), 1.0);
-        } else {
-            self.fill_cdf_binary_search(split_points, &mut ranks);
+        // 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,
         }
-        ranks.push(1.0);
-        Some(ranks)
-    }
-
-    #[inline(always)]
-    fn rank(&self, value: f64) -> Option<f64> {
-        debug_assert!(!value.is_nan(), "value must not be NaN");
 
-        if self.centroids.is_empty() {
-            return None;
-        }
-        if value < self.min {
-            return Some(0.0);
-        }
-        if value > self.max {
-            return Some(1.0);
-        }
-        if self.min == self.max {
-            return Some(0.5);
-        }
-        let total_weight = self.centroids_weight as f64;
-        let index = if value <= self.centroids[0].mean {
-            if value < self.centroids[0].mean {
-                return Some(self.mass_in_left_tail(value) / total_weight);
-            }
-            0
-        } else {
-            if value > self.centroids.last().unwrap().mean {
-                return Some(self.mass_in_right_tail(value) / total_weight);
+        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;
             }
-            self.centroids.partition_point(|c| c.mean < value)
-        };
-        let (lower, anchors) = self.rank_anchors(value, index);
-        let weight_below = self.centroids[..lower].iter().map(|c| 
c.weight.get()).sum();
-        Some(Self::interpolated_mass(value, anchors, weight_below) / 
total_weight)
-    }
+        }
 
-    #[inline(always)]
-    fn fill_cdf_binary_search(&self, split_points: &[f64], ranks: &mut 
Vec<f64>) {
-        let total_weight = self.centroids_weight as f64;
-        let mut previous_lower = 0;
-        let mut weight_below = 0;
-        for &value in split_points {
-            if value < self.min {
-                ranks.push(0.0);
-                continue;
-            }
-            if value > self.max {
-                ranks.push(1.0);
+        let reverse = first.reverse_merge;
+        let mut merged = TDigestMut::from_parts(
+            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;
             }
-            let index = if value <= self.centroids[0].mean {
-                if value < self.centroids[0].mean {
-                    ranks.push(self.mass_in_left_tail(value) / total_weight);
-                    continue;
-                }
-                0
+            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 {
-                if value > self.centroids.last().unwrap().mean {
-                    ranks.push(self.mass_in_right_tail(value) / total_weight);
-                    continue;
-                }
-                self.centroids.partition_point(|c| c.mean < value)
+                digest.buffer.centroids[0]
             };
-            let (lower, anchors) = self.rank_anchors(value, index);
-            weight_below += self.centroids[previous_lower..lower]
-                .iter()
-                .map(|c| c.weight.get())
-                .sum::<u64>();
-            previous_lower = lower;
-            ranks.push(Self::interpolated_mass(value, anchors, weight_below) / 
total_weight);
+            heap.push(MergeInput {
+                next_centroid,
+                centroids: digest.buffer.centroids.into_iter(),
+                unmerged_len,
+                input_index,
+            });
         }
-    }
 
-    #[inline(always)]
-    fn fill_cdf_scan(&self, split_points: &[f64], ranks: &mut Vec<f64>) {
-        let total_weight = self.centroids_weight as f64;
-        let mut index = 0;
-        let mut weight_before_index = 0;
-        for &value in split_points {
-            let lower_bound = if value <= self.centroids[0].mean {
-                if value < self.centroids[0].mean {
-                    ranks.push(self.mass_in_left_tail(value) / total_weight);
-                    continue;
-                }
-                0
+        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 {
-                if value > self.centroids.last().unwrap().mean {
-                    ranks.push(self.mass_in_right_tail(value) / total_weight);
-                    continue;
-                }
-                while index < self.centroids.len() && 
self.centroids[index].mean < value {
-                    weight_before_index += self.centroids[index].weight.get();
-                    index += 1;
-                }
-                index
+                input.centroids.next()
             };
-            let (lower, anchors) = self.rank_anchors(value, lower_bound);
-            // Between means, the lower anchor precedes index. Its weight 
belongs to the
-            // interpolation interval rather than the prefix below that 
interval.
-            let weight_below = weight_before_index
-                - if lower < lower_bound {
-                    self.centroids[lower].weight.get()
+            if input.centroids.len() == 0 {
+                heap.swap_remove(0);
+            } else {
+                input.next_centroid = if reverse {
+                    *input.centroids.as_slice().last().unwrap()
                 } else {
-                    0
+                    input.centroids.as_slice()[0]
                 };
-            ranks.push(Self::interpolated_mass(value, anchors, weight_below) / 
total_weight);
-        }
-    }
+            }
+            sift_down_merge_heap(&mut heap, 0, reverse);
 
-    #[inline]
-    fn mass_in_left_tail(&self, value: f64) -> f64 {
-        let center = centroid_center(0, self.centroids[0].weight.get());
-        if value == self.min {
-            0.5
-        } else {
-            interpolate(
-                center.min(1.),
-                center,
-                interpolation_fraction(value, self.min, 
self.centroids[0].mean),
-            )
+            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.merge(centroid);
+                } else {
+                    weight_so_far += last.weight();
+                    retained.push(centroid);
+                }
+            } else {
+                retained.push(centroid);
+            }
         }
-    }
 
-    #[inline]
-    fn mass_in_right_tail(&self, value: f64) -> f64 {
-        let last = self.centroids.last().unwrap();
-        let center = centroid_center(self.centroids_weight - 
last.weight.get(), last.weight.get());
-        if value == self.max {
-            // Use the same rounded mass as the adjacent centroid center.
-            centroid_center(self.centroids_weight - 1, 1)
-        } else {
-            interpolate(
-                center,
-                center.max((self.centroids_weight - 1) as f64),
-                interpolation_fraction(value, last.mean, self.max),
-            )
+        debug_assert!(retained.len() <= merged.target_centroids());
+        if reverse {
+            retained.reverse();
         }
+        debug_assert_eq!(
+            retained
+                .iter()
+                .map(|centroid| centroid.weight64())
+                .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
     }
+}
 
-    #[inline(always)]
-    fn rank_anchors(&self, value: f64, index: usize) -> (usize, &[Centroid]) {
-        // An exact mean spans its entire equal run; otherwise adjacent 
centroids bracket it.
-        if self.centroids[index].mean == value {
-            let end = self.centroids.partition_point(|c| c.mean <= value);
-            (index, &self.centroids[index..end])
-        } else {
-            (index - 1, &self.centroids[index - 1..=index])
+// Centroids are stored as `[compressed prefix | unmerged unit-weight tail]`. 
Carrying a unit weight
+// for each unmerged value lets updates, compression, and merge reuse one 
allocation instead of
+// converting raw values into a second vector during compression.
+#[derive(Debug, Clone, Default)]
+struct TDigestBuffer {
+    centroids: Vec<Centroid>,
+    unmerged_tail_len: usize,
+}
+
+impl TDigestBuffer {
+    fn new(centroids: Vec<Centroid>, unmerged_tail_len: usize) -> Self {
+        debug_assert!(unmerged_tail_len <= centroids.len());
+        TDigestBuffer {
+            centroids,
+            unmerged_tail_len,
         }
     }
 
-    #[inline(always)]
-    fn interpolated_mass(value: f64, anchors: &[Centroid], weight_below: u64) 
-> f64 {
-        let left = &anchors[0];
-        let Some((right, middle)) = anchors[1..].split_last() else {
-            return centroid_center(weight_below, left.weight.get());
-        };
-        let weight_between = left.weight.get() + middle.iter().map(|c| 
c.weight.get()).sum::<u64>();
-        let fraction = if left.mean < right.mean {
-            interpolation_fraction(value, left.mean, right.mean)
-        } else {
-            0.5
-        };
-        interpolate(
-            centroid_center(weight_below, left.weight.get()),
-            centroid_center(weight_below + weight_between, right.weight.get()),
-            fraction,
-        )
+    fn len(&self) -> usize {
+        self.centroids.len()
     }
 
-    #[inline]
-    fn quantile(&self, rank: f64) -> Option<f64> {
-        debug_assert!((0.0..=1.0).contains(&rank), "rank must be in [0.0, 
1.0]");
+    fn is_empty(&self) -> bool {
+        self.centroids.is_empty()
+    }
 
-        if self.centroids.is_empty() {
-            return None;
+    fn unmerged_len(&self) -> usize {
+        self.unmerged_tail_len
+    }
+
+    fn compressed_prefix_len(&self) -> usize {
+        self.centroids.len() - self.unmerged_tail_len
+    }
+
+    fn push_unmerged(&mut self, value: f64, max_unmerged: usize) {
+        debug_assert!(self.unmerged_tail_len < max_unmerged);
+        if self.centroids.len() == self.centroids.capacity() {
+            let target_unmerged = if self.unmerged_tail_len == 0 {
+                INITIAL_UNMERGED_CAPACITY
+            } else if self.unmerged_tail_len == INITIAL_UNMERGED_CAPACITY {
+                // Once a digest outgrows a tiny group, skip an extra 
allocator round trip while
+                // keeping the first allocation small.
+                (INITIAL_UNMERGED_CAPACITY * UNMERGED_MULTIPLIER * 
UNMERGED_MULTIPLIER)
+                    .min(max_unmerged)
+            } else {
+                self.unmerged_tail_len
+                    .saturating_mul(UNMERGED_MULTIPLIER)
+                    .min(max_unmerged)
+            };
+            let target_capacity = 
self.compressed_prefix_len().saturating_add(target_unmerged);
+            self.centroids
+                
.reserve_exact(target_capacity.saturating_sub(self.centroids.len()));
         }
 
-        let mut quantile = [0.];
-        self.fill_quantiles(std::iter::once((0, rank)), &mut quantile);
-        Some(quantile[0])
+        self.centroids.push(Centroid {
+            mean: value,
+            weight: DEFAULT_WEIGHT,
+        });
+        self.unmerged_tail_len += 1;
     }
 
-    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]"
-        );
+    /// Returns all centroids in the tie order expected by stable compression 
sorting.
+    ///
+    /// The buffer is rotated from `[compressed | unmerged]` to `[unmerged | 
compressed]`, so new
+    /// values stay before existing centroids when their means are equal.
+    fn into_centroids_for_compression(mut self) -> Vec<Centroid> {
+        debug_assert_ne!(self.unmerged_tail_len, 0);
+        let compressed_prefix_len = self.compressed_prefix_len();
+        self.centroids.rotate_left(compressed_prefix_len);
+        self.centroids
+    }
 
-        if self.centroids.is_empty() {
-            return None;
+    /// 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 {
+            // 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;
         }
 
-        let mut quantiles = vec![0.; ranks.len()];
-        if ranks.is_sorted() {
-            self.fill_quantiles(ranks.iter().copied().enumerate(), &mut 
quantiles);
-            return Some(quantiles);
-        }
+        let compressed_prefix_len = self.compressed_prefix_len();
+        self.centroids.reserve(other.len());
+        let other_prefix_len = other.compressed_prefix_len();
+        self.centroids
+            .extend_from_slice(&other.centroids[other_prefix_len..]);
+        self.centroids
+            .extend_from_slice(&other.centroids[..other_prefix_len]);
+        // Preserve the stable tie order: left unmerged, right unmerged and 
compressed, then the
+        // left compressed prefix.
+        self.centroids.rotate_left(compressed_prefix_len);
+        self.centroids.sort_by(centroid_cmp);
+        self.centroids
+    }
 
-        // 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,
+    #[inline]
+    fn compressed_centroids(&self) -> &[Centroid] {
+        assert_eq!(
+            self.unmerged_tail_len, 0,
+            "t-digest buffer must be compressed before reading centroids"
         );
-        Some(quantiles)
+        &self.centroids
     }
 
-    /// Answers nondecreasing ranks at their original output indices.
-    fn fill_quantiles(
-        &self,
-        ranks: impl DoubleEndedIterator<Item = (usize, f64)>,
-        quantiles: &mut [f64],
-    ) {
-        if self.min == self.max {
-            quantiles.fill(self.min);
-            return;
-        }
+    fn into_compressed_centroids(self) -> Vec<Centroid> {
+        assert_eq!(
+            self.unmerged_tail_len, 0,
+            "t-digest buffer must be compressed before reading centroids"
+        );
+        self.centroids
+    }
 
-        let centroids_weight = self.centroids_weight as f64;
-        let mut queries = ranks
-            .map(|(index, rank)| (index, rank * centroids_weight))
-            .rev()
-            .peekable();
+    /// Returns an estimate of memory usage in bytes.
+    fn estimated_size(&self) -> usize {
+        self.centroids.capacity() * size_of::<Centroid>()
+    }
+}
 
-        // Consume the right tail from the back, so the centroid scan can omit 
all tail checks.
-        while let Some((index, _)) = queries.next_if(|&(_, weight)| 
centroids_weight - weight < 1.)
-        {
-            quantiles[index] = self.max;
-        }
-        if queries.peek().is_none() {
-            return;
-        }
+struct TDigestView<'a> {
+    min: f64,
+    max: f64,
+    centroids: &'a [Centroid],
+    centroids_weight: u64,
+}
 
-        let last = self.centroids.last().unwrap();
-        let last_weight = last.weight.get();
-        let last_center = centroid_center(self.centroids_weight - last_weight, 
last_weight);
-        if last_weight > 1 {
-            let tail_end = (self.centroids_weight - 1) as f64;
-            while let Some((index, weight)) = queries.next_if(|&(_, weight)| 
weight >= last_center)
-            {
-                // Integer counts can round a wider tail to zero width as 
well. At this
-                // boundary prefer the maximum, as for an exact two-sample 
tail.
-                quantiles[index] = if last_center == tail_end {
-                    self.max
-                } else {
-                    interpolate(
-                        last.mean,
-                        self.max,
-                        (weight - last_center) / (tail_end - last_center),
-                    )
-                };
-            }
+impl TDigestView<'_> {
+    fn pmf(&self, split_points: &[f64]) -> Option<Vec<f64>> {
+        let mut buckets = self.cdf(split_points)?;
+        let mut previous = buckets[0];
+        for bucket in &mut buckets[1..] {
+            let rank = *bucket;
+            *bucket -= previous;
+            previous = rank;
         }
+        Some(buckets)
+    }
 
-        let mut queries = queries.rev().peekable();
-        while let Some((index, _)) = queries.next_if(|&(_, weight)| weight < 
1.) {
-            quantiles[index] = self.min;
+    fn cdf(&self, split_points: &[f64]) -> Option<Vec<f64>> {
+        if self.centroids.is_empty() {
+            return None;
         }
-        if queries.peek().is_none() {
-            return;
+        if split_points.is_empty() {
+            return Some(vec![1.0]);
         }
 
-        let first = &self.centroids[0];
-        let first_weight = first.weight.get();
-        let first_center = centroid_center(0, first_weight);
-        if first_weight > 1 {
-            // At first_weight == 2, the weight < 1 pre-pass already consumed 
this interval.
-            while let Some((index, weight)) = queries.next_if(|&(_, weight)| 
weight < first_center)
-            {
-                quantiles[index] =
-                    interpolate(self.min, first.mean, (weight - 1.) / 
(first_center - 1.));
+        let binary_search_cost = self.centroids.len().ilog2() as usize;
+        let use_scan = split_points.len().saturating_mul(binary_search_cost) > 
self.centroids.len();
+        let mut ranks = Vec::with_capacity(split_points.len() + 1);
+        if use_scan || self.min == self.max {
+            // Constant sketches and scans can fill the sorted outer tails 
directly.
+            let start = split_points.partition_point(|&p| p < self.min);
+            let end = start + split_points[start..].partition_point(|&p| p <= 
self.max);
+            ranks.resize(start, 0.0);
+            if self.min == self.max {
+                ranks.resize(end, 0.5);
+            } else {
+                self.fill_cdf_scan(&split_points[start..end], &mut ranks);
             }
+            ranks.resize(split_points.len(), 1.0);
+        } else {
+            self.fill_cdf_binary_search(split_points, &mut ranks);
         }
-        if queries.peek().is_none() {
-            return;
-        }
+        ranks.push(1.0);
+        Some(ranks)
+    }
 
-        // Answer the remaining queries by centroid interval; both streams 
advance only forward.
-        let mut weight_before = 0;
-        let mut left_center = first_center;
-        for pair in self.centroids.windows(2) {
-            let left = &pair[0];
-            let right = &pair[1];
-            let right_before = weight_before + left.weight.get();
-            let right_center = centroid_center(right_before, 
right.weight.get());
-            loop {
-                let Some(&(index, weight)) = queries.peek() else {
-                    return;
-                };
-                if right_center <= weight {
-                    break;
-                }
-                queries.next();
-                let mut start = left_center;
-                if left.weight.get() == 1 {
-                    start = right_before as f64;
-                    if weight < start {
-                        quantiles[index] = left.mean;
-                        continue;
-                    }
-                }
-                let mut end = right_center;
-                if right.weight.get() == 1 {
-                    end = right_before as f64;
-                    if weight >= end {
-                        quantiles[index] = right.mean;
-                        continue;
-                    }
-                }
-                quantiles[index] =
-                    interpolate(left.mean, right.mean, (weight - start) / (end 
- start));
-            }
-            weight_before = right_before;
-            left_center = right_center;
-        }
+    #[inline(always)]
+    fn rank(&self, value: f64) -> Option<f64> {
+        debug_assert!(!value.is_nan(), "value must not be NaN");
 
-        // Rounding at large total weights can exhaust the scan near the 
maximum.
-        for (index, _) in queries {
-            quantiles[index] = self.max;
+        if self.centroids.is_empty() {
+            return None;
         }
+        if value < self.min {
+            return Some(0.0);
+        }
+        if value > self.max {
+            return Some(1.0);
+        }
+        if self.min == self.max {
+            return Some(0.5);
+        }
+        let total_weight = self.centroids_weight as f64;
+        let index = if value <= self.centroids[0].mean {
+            if value < self.centroids[0].mean {
+                return Some(self.left_tail_weight(value) / total_weight);
+            }
+            0
+        } else {
+            if value > self.centroids.last().unwrap().mean {
+                return Some(self.right_tail_weight(value) / total_weight);
+            }
+            self.centroids.partition_point(|c| c.mean < value)
+        };
+        let (lower, interval) = self.interpolation_interval(value, index);
+        let weight_below = self.centroids[..lower].iter().map(|c| 
c.weight64()).sum();
+        Some(interpolate_weight(value, interval, weight_below) / total_weight)
     }
-}
-
-/// Checks the sequential validity of the given array of double values.
-/// They must be unique, monotonically increasing and not NaN.
-fn check_split_points(split_points: &[f64]) -> Result<(), Error> {
-    if split_points.iter().any(|split_point| split_point.is_nan()) {
-        return Err(Error::invalid_argument(
-            "split_points must not contain NaN values",
-        ));
-    }
-    if !split_points.windows(2).all(|pair| pair[0] < pair[1]) {
-        return Err(Error::invalid_argument(
-            "split_points must be unique and monotonically increasing",
-        ));
+
+    #[inline(always)]
+    fn fill_cdf_binary_search(&self, split_points: &[f64], ranks: &mut 
Vec<f64>) {
+        let total_weight = self.centroids_weight as f64;
+        let mut previous_lower = 0;
+        let mut weight_below = 0;
+        for &value in split_points {
+            if value < self.min {
+                ranks.push(0.0);
+                continue;
+            }
+            if value > self.max {
+                ranks.push(1.0);
+                continue;
+            }
+            let index = if value <= self.centroids[0].mean {
+                if value < self.centroids[0].mean {
+                    ranks.push(self.left_tail_weight(value) / total_weight);
+                    continue;
+                }
+                0
+            } else {
+                if value > self.centroids.last().unwrap().mean {
+                    ranks.push(self.right_tail_weight(value) / total_weight);
+                    continue;
+                }
+                self.centroids.partition_point(|c| c.mean < value)
+            };
+            let (lower, interval) = self.interpolation_interval(value, index);
+            weight_below += self.centroids[previous_lower..lower]
+                .iter()
+                .map(|c| c.weight64())
+                .sum::<u64>();
+            previous_lower = lower;
+            ranks.push(interpolate_weight(value, interval, weight_below) / 
total_weight);
+        }
     }
-    Ok(())
-}
 
-fn check_rank(rank: f64) -> Result<(), Error> {
-    if !(0.0..=1.0).contains(&rank) {
-        return Err(Error::invalid_argument(format!(
-            "rank must be in [0.0, 1.0], got {rank}"
-        )));
+    #[inline(always)]
+    fn fill_cdf_scan(&self, split_points: &[f64], ranks: &mut Vec<f64>) {
+        let total_weight = self.centroids_weight as f64;
+        let mut index = 0;
+        let mut weight_before_index = 0;
+        for &value in split_points {
+            let lower_bound = if value <= self.centroids[0].mean {
+                if value < self.centroids[0].mean {
+                    ranks.push(self.left_tail_weight(value) / total_weight);
+                    continue;
+                }
+                0
+            } else {
+                if value > self.centroids.last().unwrap().mean {
+                    ranks.push(self.right_tail_weight(value) / total_weight);
+                    continue;
+                }
+                while index < self.centroids.len() && 
self.centroids[index].mean < value {
+                    weight_before_index += self.centroids[index].weight64();
+                    index += 1;
+                }
+                index
+            };
+            let (lower, interval) = self.interpolation_interval(value, 
lower_bound);
+            // Between means, the lower anchor precedes index. Its weight 
belongs to the
+            // interpolation interval rather than the prefix below that 
interval.
+            let weight_below = weight_before_index
+                - if lower < lower_bound {
+                    self.centroids[lower].weight64()
+                } else {
+                    0
+                };
+            ranks.push(interpolate_weight(value, interval, weight_below) / 
total_weight);
+        }
     }
-    Ok(())
-}
 
-fn check_ranks(ranks: &[f64]) -> Result<(), Error> {
-    for &rank in ranks {
-        check_rank(rank)?;
+    #[inline]
+    fn left_tail_weight(&self, value: f64) -> f64 {
+        let center = centroid_weight_center(0, self.centroids[0].weight64());
+        if value == self.min {
+            0.5
+        } else {
+            interpolate(
+                center.min(1.),
+                center,
+                interpolation_fraction(value, self.min, 
self.centroids[0].mean),
+            )
+        }
     }
-    Ok(())
-}
 
-fn centroid_cmp(a: &Centroid, b: &Centroid) -> Ordering {
-    match a.mean.partial_cmp(&b.mean) {
-        Some(order) => order,
-        None => unreachable!("NaN values should never be present in 
centroids"),
+    #[inline]
+    fn right_tail_weight(&self, value: f64) -> f64 {
+        let last = self.centroids.last().unwrap();
+        let center =
+            centroid_weight_center(self.centroids_weight - last.weight64(), 
last.weight64());
+        if value == self.max {
+            // Use the same rounded weight as the adjacent centroid center.
+            centroid_weight_center(self.centroids_weight - 1, 1)
+        } else {
+            interpolate(
+                center,
+                center.max((self.centroids_weight - 1) as f64),
+                interpolation_fraction(value, last.mean, self.max),
+            )
+        }
     }
-}
 
-fn centroids_are_sorted(centroids: &[Centroid]) -> bool {
-    centroids
-        .windows(2)
-        .all(|pair| centroid_cmp(&pair[0], &pair[1]) != Ordering::Greater)
-}
+    #[inline(always)]
+    fn interpolation_interval(&self, value: f64, index: usize) -> (usize, 
&[Centroid]) {
+        // An exact mean spans its entire equal run; otherwise adjacent 
centroids bracket it.
+        if self.centroids[index].mean == value {
+            let end = self.centroids.partition_point(|c| c.mean <= value);
+            (index, &self.centroids[index..end])
+        } else {
+            (index - 1, &self.centroids[index - 1..=index])
+        }
+    }
 
-/// 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;
+    #[inline]
+    fn quantile(&self, rank: f64) -> Option<f64> {
+        debug_assert!((0.0..=1.0).contains(&rank), "rank must be in [0.0, 
1.0]");
+
+        if self.centroids.is_empty() {
+            return None;
+        }
+
+        let mut quantile = [0.];
+        self.fill_quantiles(std::iter::once((0, rank)), &mut quantile);
+        Some(quantile[0])
     }
-    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));
-    debug_assert!(centroids_are_sorted(right));
+    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]"
+        );
 
-    let mut left_index = left.len();
-    let mut right_index = right.len();
-    let mut output_index = left_index + right_index;
-    left.reserve(right.len());
-    left.resize(output_index, right[0]);
+        if self.centroids.is_empty() {
+            return None;
+        }
 
-    while left_index > 0 && right_index > 0 {
-        let left_centroid = left[left_index - 1];
-        let right_centroid = right[right_index - 1];
-        output_index -= 1;
-        // Taking the left side on ties while filling backward keeps the right 
side first in the
-        // final order, matching a stable sort after rotating the compressed 
left prefix.
-        if centroid_cmp(&left_centroid, &right_centroid) != Ordering::Less {
-            left_index -= 1;
-            left[output_index] = left_centroid;
-        } else {
-            right_index -= 1;
-            left[output_index] = right_centroid;
+        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.min == self.max {
+            quantiles.fill(self.min);
+            return;
+        }
+
+        let centroids_weight = self.centroids_weight as f64;
+        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)| 
centroids_weight - weight < 1.)
+        {
+            quantiles[index] = self.max;
+        }
+        if queries.peek().is_none() {
+            return;
+        }
+
+        let last = self.centroids.last().unwrap();
+        let last_weight = last.weight64();
+        let last_center = centroid_weight_center(self.centroids_weight - 
last_weight, last_weight);
+        if last_weight > 1 {
+            let tail_end = (self.centroids_weight - 1) as f64;
+            while let Some((index, weight)) = queries.next_if(|&(_, weight)| 
weight >= last_center)
+            {
+                // Integer counts can round a wider tail to zero width as 
well. At this
+                // boundary prefer the maximum, as for an exact two-sample 
tail.
+                quantiles[index] = if last_center == tail_end {
+                    self.max
+                } else {
+                    interpolate(
+                        last.mean,
+                        self.max,
+                        (weight - last_center) / (tail_end - last_center),
+                    )
+                };
+            }
+        }
+
+        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.weight64();
+        let first_center = centroid_weight_center(0, first_weight);
+        if first_weight > 1 {
+            // At first_weight == 2, the weight < 1 pre-pass already consumed 
this interval.
+            while let Some((index, weight)) = queries.next_if(|&(_, weight)| 
weight < first_center)
+            {
+                quantiles[index] =
+                    interpolate(self.min, first.mean, (weight - 1.) / 
(first_center - 1.));
+            }
+        }
+        if queries.peek().is_none() {
+            return;
+        }
+
+        // Answer the remaining queries by centroid interval; both streams 
advance only forward.
+        let mut weight_before = 0;
+        let mut left_center = first_center;
+        for pair in self.centroids.windows(2) {
+            let left = &pair[0];
+            let right = &pair[1];
+            let right_before = weight_before + left.weight64();
+            let right_center = centroid_weight_center(right_before, 
right.weight64());
+            loop {
+                let Some(&(index, weight)) = queries.peek() else {
+                    return;
+                };
+                if right_center <= weight {
+                    break;
+                }
+                queries.next();
+                let mut start = left_center;
+                if left.weight64() == 1 {
+                    start = right_before as f64;
+                    if weight < start {
+                        quantiles[index] = left.mean;
+                        continue;
+                    }
+                }
+                let mut end = right_center;
+                if right.weight64() == 1 {
+                    end = right_before as f64;
+                    if weight >= end {
+                        quantiles[index] = right.mean;
+                        continue;
+                    }
+                }
+                quantiles[index] =
+                    interpolate(left.mean, right.mean, (weight - start) / (end 
- start));
+            }
+            weight_before = right_before;
+            left_center = right_center;
+        }
+
+        // Rounding at large total weights can exhaust the scan near the 
maximum.
+        for (index, _) in queries {
+            quantiles[index] = self.max;
+        }
+    }
+}
+
+/// Checks the sequential validity of the given array of double values.
+/// They must be unique, monotonically increasing and not NaN.
+fn check_split_points(split_points: &[f64]) -> Result<(), Error> {
+    if split_points.iter().any(|split_point| split_point.is_nan()) {
+        return Err(Error::invalid_argument(
+            "split_points must not contain NaN values",
+        ));
+    }
+    if !split_points.windows(2).all(|pair| pair[0] < pair[1]) {
+        return Err(Error::invalid_argument(
+            "split_points must be unique and monotonically increasing",
+        ));
+    }
+    Ok(())
+}
+
+fn check_rank(rank: f64) -> Result<(), Error> {
+    if !(0.0..=1.0).contains(&rank) {
+        return Err(Error::invalid_argument(format!(
+            "rank must be in [0.0, 1.0], got {rank}"
+        )));
+    }
+    Ok(())
+}
+
+fn check_ranks(ranks: &[f64]) -> Result<(), Error> {
+    for &rank in ranks {
+        check_rank(rank)?;
+    }
+    Ok(())
+}
+
+fn centroid_cmp(a: &Centroid, b: &Centroid) -> Ordering {
+    match a.mean.partial_cmp(&b.mean) {
+        Some(order) => order,
+        None => unreachable!("NaN values should never be present in 
centroids"),
+    }
+}
+
+fn centroids_are_sorted(centroids: &[Centroid]) -> bool {
+    centroids
+        .windows(2)
+        .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));
+    debug_assert!(centroids_are_sorted(right));
+
+    let mut left_index = left.len();
+    let mut right_index = right.len();
+    let mut output_index = left_index + right_index;
+    left.reserve(right.len());
+    left.resize(output_index, right[0]);
+
+    while left_index > 0 && right_index > 0 {
+        let left_centroid = left[left_index - 1];
+        let right_centroid = right[right_index - 1];
+        output_index -= 1;
+        // Taking the left side on ties while filling backward keeps the right 
side first in the
+        // final order, matching a stable sort after rotating the compressed 
left prefix.
+        if centroid_cmp(&left_centroid, &right_centroid) != Ordering::Less {
+            left_index -= 1;
+            left[output_index] = left_centroid;
+        } else {
+            right_index -= 1;
+            left[output_index] = right_centroid;
+        }
+    }
+    if right_index > 0 {
+        left[..right_index].copy_from_slice(&right[..right_index]);
+    }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+struct Centroid {
+    mean: f64,
+    weight: NonZeroU64,
+}
+
+impl Centroid {
+    fn merge(&mut self, other: Centroid) {
+        let (self_weight, other_weight) = (self.weight(), other.weight());
+        let total_weight = self_weight + other_weight;
+        self.weight = self
+            .weight
+            .checked_add(other.weight64())
+            .expect("weight overflow");
+
+        // Start at the heavier centroid so a small contribution is not 
rounded away in 1 - ratio.
+        let (start, end, fraction) = if self_weight >= other_weight {
+            (self.mean, other.mean, other_weight / total_weight)
+        } else {
+            (other.mean, self.mean, self_weight / total_weight)
+        };
+        debug_assert!(start.is_finite() && end.is_finite() && fraction > 0. && 
fraction <= 0.5);
+
+        // Same-sign means within a factor of two in magnitude subtract 
exactly. For wider
+        // gaps, a step of at most 1/2 cannot cross either endpoint even if 
subtraction rounds.
+        // Queries can interpolate farther and still need the general bounds.
+        self.mean = if start.is_sign_positive() == end.is_sign_positive() {
+            (end - start).mul_add(fraction, start)
+        } else if 1. - fraction == 1. {
+            // Both corrections can matter even when 1 - fraction rounds to 1.
+            // Halving before subtraction avoids overflow; the FMA combines the
+            // correction with start before rounding the result.
+            (end * 0.5 - start * 0.5).mul_add(2. * fraction, start)
+        } else {
+            interpolate(start, end, fraction)
+        };
+    }
+
+    fn weight(&self) -> f64 {
+        self.weight64() as f64
+    }
+
+    fn weight64(&self) -> u64 {
+        self.weight.get()
+    }
+}
+
+/// Generates cluster sizes proportional to `q*(1-q)`.
+///
+/// The use of a normalizing function results in a strictly bounded number of 
clusters no matter
+/// how many samples.
+///
+/// Corresponds to K_2 in the reference implementation
+mod scale_function {
+    pub fn max(q: f64, normalizer: f64) -> f64 {
+        q * (1. - q) / normalizer
+    }
+
+    pub fn normalizer(compression: f64, n: f64) -> f64 {
+        let z = 4. * (n / compression).ln() + 24.;
+        compression / z
+    }
+}
+
+#[inline(always)]
+fn interpolate_weight(value: f64, interval: &[Centroid], weight_below: u64) -> 
f64 {
+    let left = &interval[0];
+    let Some((right, middle)) = interval[1..].split_last() else {
+        return centroid_weight_center(weight_below, left.weight64());
+    };
+    let weight_between = left.weight64() + middle.iter().map(|c| 
c.weight64()).sum::<u64>();
+    let fraction = if left.mean < right.mean {
+        interpolation_fraction(value, left.mean, right.mean)
+    } else {
+        0.5
+    };
+    interpolate(
+        centroid_weight_center(weight_below, left.weight64()),
+        centroid_weight_center(weight_below + weight_between, 
right.weight64()),
+        fraction,
+    )
+}
+
+/// Keeps cumulative counts exact until a centroid's midpoint is needed for 
interpolation.
+/// All intervals and tails use this same center to avoid inconsistent 
boundary rounding.
+fn centroid_weight_center(weight_before: u64, weight: u64) -> f64 {
+    (weight_before + weight / 2) as f64 + (weight % 2) as f64 * 0.5
+}
+
+/// Interpolates finite endpoints at a fraction in `[0, 1]`, preserving their 
bounds.
+///
+/// The sign split follows WG21 P0811R3:
+/// <https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p0811r3.html>.
+fn interpolate(start: f64, end: f64, fraction: f64) -> f64 {
+    debug_assert!(start.is_finite() && end.is_finite() && 
(0.0..=1.0).contains(&fraction));
+    // Callers guarantee the fraction's range, but division may round it to an 
endpoint.
+    // Even an FMA need not reproduce `end` at 1: its subtracted difference 
already rounded.
+    if fraction <= 0. {
+        return start;
+    }
+    if fraction >= 1. {
+        return end;
+    }
+    if start.is_sign_positive() != end.is_sign_positive() {
+        // Avoid the potentially overflowing difference of opposite-signed 
endpoints.
+        start * (1. - fraction) + end * fraction
+    } else {
+        // Same-sign subtraction is finite; rounding must not overshoot the 
end.
+        let value = (end - start).mul_add(fraction, start);
+        if start < end {
+            value.min(end)
+        } else {
+            value.max(end)
+        }
+    }
+}
+
+/// Locates a value between distinct finite endpoints.
+fn interpolation_fraction(value: f64, start: f64, end: f64) -> f64 {
+    let width = end - start;
+    if width.is_finite() {
+        (value - start) / width
+    } else {
+        // Scaling is needed only when opposite-signed endpoints overflow 
their difference.
+        (value * 0.5 - start * 0.5) / (end * 0.5 - start * 0.5)
+    }
+}
+
+const PREAMBLE_LONGS_EMPTY_OR_SINGLE: u8 = 1;
+const PREAMBLE_LONGS_MULTIPLE: u8 = 2;
+const SERIAL_VERSION: u8 = 1;
+const FLAGS_IS_EMPTY: u8 = 1 << 0;
+const FLAGS_IS_SINGLE_VALUE: u8 = 1 << 1;
+const FLAGS_REVERSE_MERGE: u8 = 1 << 2;
+/// the format of the reference implementation is using double (f64) precision
+const COMPAT_DOUBLE: u32 = 1;
+/// the format of the reference implementation is using float (f32) precision
+const COMPAT_FLOAT: u32 = 2;
+
+fn serialize_compressed(
+    k: u16,
+    reverse_merge: bool,
+    min: f64,
+    max: f64,
+    centroids: &[Centroid],
+    total_weight: u64,
+) -> Vec<u8> {
+    let is_empty = centroids.is_empty();
+    let is_single_value = total_weight == 1;
+    let mut total_size = if is_empty || is_single_value {
+        // Preamble, serial version, family, k, flags, and two unused bytes.
+        size_of::<u64>()
+    } else {
+        // The short header plus centroid and buffered-value counts.
+        size_of::<u64>() * 2
+    };
+    if is_single_value {
+        total_size += size_of::<f64>();
+    } else if !is_empty {
+        total_size += size_of::<f64>() * 2;
+        total_size += centroids.len() * (size_of::<f64>() + size_of::<u64>());
+    }
+
+    let mut bytes = SketchBytes::with_capacity(total_size);
+    bytes.write_u8(if is_empty || is_single_value {
+        PREAMBLE_LONGS_EMPTY_OR_SINGLE
+    } else {
+        PREAMBLE_LONGS_MULTIPLE
+    });
+    bytes.write_u8(SERIAL_VERSION);
+    bytes.write_u8(Family::TDIGEST.id);
+    bytes.write_u16_le(k);
+    bytes.write_u8({
+        let mut flags = 0;
+        if is_empty {
+            flags |= FLAGS_IS_EMPTY;
+        }
+        if is_single_value {
+            flags |= FLAGS_IS_SINGLE_VALUE;
+        }
+        if reverse_merge {
+            flags |= FLAGS_REVERSE_MERGE;
+        }
+        flags
+    });
+    bytes.write_u16_le(0); // unused
+    if is_empty {
+        return bytes.into_bytes();
+    }
+    if is_single_value {
+        bytes.write_f64_le(min);
+        return bytes.into_bytes();
+    }
+    bytes.write_u32_le(centroids.len() as u32);
+    bytes.write_u32_le(0); // no buffered values
+    bytes.write_f64_le(min);
+    bytes.write_f64_le(max);
+    for centroid in centroids {
+        bytes.write_f64_le(centroid.mean);
+        bytes.write_u64_le(centroid.weight64());
+    }
+    bytes.into_bytes()
+}
+
+impl TDigestMut {
+    fn deserialize_impl(bytes: &[u8], is_f32: bool) -> Result<Self, Error> {
+        let mut cursor = SketchSlice::new(bytes);
+
+        let preamble_longs = cursor
+            .read_u8()
+            .map_err(insufficient_data("preamble_longs"))?;
+        let serial_version = cursor
+            .read_u8()
+            .map_err(insufficient_data("serial_version"))?;
+        let family_id = 
cursor.read_u8().map_err(insufficient_data("family_id"))?;
+        if let Err(err) = Family::TDIGEST.validate_id(family_id) {
+            return if preamble_longs == 0 && serial_version == 0 && family_id 
== 0 {
+                Self::deserialize_compat(bytes)
+            } else {
+                Err(err)
+            };
+        }
+        ensure_serial_version_is(SERIAL_VERSION, serial_version)?;
+        let k = cursor.read_u16_le().map_err(insufficient_data("k"))?;
+        if k < 10 {
+            return Err(Error::deserial(format!("k must be at least 10, got 
{k}")));
+        }
+        let flags = cursor.read_u8().map_err(insufficient_data("flags"))?;
+        let known_flags = FLAGS_IS_EMPTY | FLAGS_IS_SINGLE_VALUE | 
FLAGS_REVERSE_MERGE;
+        if flags & !known_flags != 0 {
+            return Err(Error::deserial(format!(
+                "malformed data: unknown TDigest flags 0x{:02x}",
+                flags & !known_flags
+            )));
+        }
+        let is_empty = (flags & FLAGS_IS_EMPTY) != 0;
+        let is_single_value = (flags & FLAGS_IS_SINGLE_VALUE) != 0;
+        if is_empty && is_single_value {
+            return Err(Error::deserial(
+                "malformed data: empty and single-value flags are mutually 
exclusive",
+            ));
+        }
+        let expected_preamble_longs = if is_empty || is_single_value {
+            PREAMBLE_LONGS_EMPTY_OR_SINGLE
+        } else {
+            PREAMBLE_LONGS_MULTIPLE
+        };
+        ensure_preamble_longs_in(&[expected_preamble_longs], preamble_longs)?;
+        cursor
+            .read_u16_le()
+            .map_err(insufficient_data("<unused>"))?; // unused
+        if is_empty {
+            return TDigestMut::new(k);
+        }
+
+        let reverse_merge = (flags & FLAGS_REVERSE_MERGE) != 0;
+        if is_single_value {
+            let value = if is_f32 {
+                cursor
+                    .read_f32_le()
+                    .map_err(insufficient_data("single_value"))? as f64
+            } else {
+                cursor
+                    .read_f64_le()
+                    .map_err(insufficient_data("single_value"))?
+            };
+            check_non_nan(value, "single_value")?;
+            check_finite(value, "single_value")?;
+            return Ok(TDigestMut::from_parts(
+                k,
+                reverse_merge,
+                value,
+                value,
+                TDigestBuffer::new(
+                    vec![Centroid {
+                        mean: value,
+                        weight: DEFAULT_WEIGHT,
+                    }],
+                    0,
+                ),
+                1,
+            ));
+        }
+        let num_centroids = cursor
+            .read_u32_le()
+            .map_err(insufficient_data("num_centroids"))? as usize;
+        let num_buffered = cursor
+            .read_u32_le()
+            .map_err(insufficient_data("num_buffered"))? as usize;
+        let (min, max) = if is_f32 {
+            (
+                cursor.read_f32_le().map_err(insufficient_data("min"))? as f64,
+                cursor.read_f32_le().map_err(insufficient_data("max"))? as f64,
+            )
+        } else {
+            (
+                cursor.read_f64_le().map_err(insufficient_data("min"))?,
+                cursor.read_f64_le().map_err(insufficient_data("max"))?,
+            )
+        };
+        check_extrema(min, max, "TDigest")?;
+        let (centroid_bytes, buffered_value_bytes) = if is_f32 {
+            (size_of::<f32>() + size_of::<u32>(), size_of::<f32>())
+        } else {
+            (size_of::<f64>() + size_of::<u64>(), size_of::<f64>())
+        };
+        let centroid_payload_bytes = num_centroids
+            .checked_mul(centroid_bytes)
+            .ok_or_else(|| Error::deserial("TDigest payload size exceeds the 
supported size"))?;
+        let buffered_payload_bytes = num_buffered
+            .checked_mul(buffered_value_bytes)
+            .ok_or_else(|| Error::deserial("TDigest payload size exceeds the 
supported size"))?;
+        let required_payload_bytes = centroid_payload_bytes
+            .checked_add(buffered_payload_bytes)
+            .ok_or_else(|| Error::deserial("TDigest payload size exceeds the 
supported size"))?;
+        // Check the whole payload once so fixed-width records can be decoded 
without per-field I/O.
+        let available_bytes = cursor.remaining().len();
+        if available_bytes < required_payload_bytes {
+            return Err(Error::insufficient_data_of(
+                "TDigest payload",
+                format_args!("expected {required_payload_bytes} bytes, got 
{available_bytes}"),
+            ));
+        }
+        let payload = &cursor.remaining()[..required_payload_bytes];
+        let (centroid_payload, buffered_payload) = 
payload.split_at(centroid_payload_bytes);
+        let stored_centroids = 
num_centroids.checked_add(num_buffered).ok_or_else(|| {
+            Error::deserial("num_centroids and num_buffered exceed the 
supported size")
+        })?;
+        if stored_centroids == 0 {
+            return Err(Error::deserial(
+                "malformed data: non-empty TDigest must contain a centroid or 
buffered value",
+            ));
+        }
+        let mut centroids = Vec::with_capacity(stored_centroids);
+        let mut compressed_weight = 0u64;
+        let mut previous_mean = min;
+        let mut centroid_means_valid = true;
+        for bytes in centroid_payload.chunks_exact(centroid_bytes) {
+            let (mean, weight) = if is_f32 {
+                (
+                    f32::from_le_bytes(bytes[..4].try_into().unwrap()) as f64,
+                    u32::from_le_bytes(bytes[4..].try_into().unwrap()) as u64,
+                )
+            } else {
+                (
+                    f64::from_le_bytes(bytes[..8].try_into().unwrap()),
+                    u64::from_le_bytes(bytes[8..].try_into().unwrap()),
+                )
+            };
+            centroid_means_valid &= mean.is_finite() & (mean >= previous_mean) 
& (mean <= max);
+            previous_mean = mean;
+            let weight = check_nonzero(weight, "centroid weight")?;
+            compressed_weight = checked_weight_sum(compressed_weight, 
weight.get())?;
+            centroids.push(Centroid { mean, weight });
         }
+        if !centroid_means_valid {
+            return Err(Error::deserial(
+                "malformed data: centroid means must be finite, within 
extrema, and nondecreasing",
+            ));
+        }
+        let total_weight = checked_weight_sum(compressed_weight, num_buffered 
as u64)?;
+        check_single_sample_extrema(min, max, total_weight)?;
+        let mut buffered_values_valid = true;
+        for bytes in buffered_payload.chunks_exact(buffered_value_bytes) {
+            let value = if is_f32 {
+                f32::from_le_bytes(bytes.try_into().unwrap()) as f64
+            } else {
+                f64::from_le_bytes(bytes.try_into().unwrap())
+            };
+            buffered_values_valid &= value.is_finite() & (value >= min) & 
(value <= max);
+            centroids.push(Centroid {
+                mean: value,
+                weight: DEFAULT_WEIGHT,
+            });
+        }
+        if !buffered_values_valid {
+            return Err(Error::deserial(
+                "malformed data: buffered values must be finite and within 
extrema",
+            ));
+        }
+        Ok(TDigestMut::from_parts(
+            k,
+            reverse_merge,
+            min,
+            max,
+            TDigestBuffer::new(centroids, num_buffered),
+            compressed_weight,
+        ))
     }
-    if right_index > 0 {
-        left[..right_index].copy_from_slice(&right[..right_index]);
-    }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq)]
-struct Centroid {
-    mean: f64,
-    weight: NonZeroU64,
-}
-
-impl Centroid {
-    fn add(&mut self, other: Centroid) {
-        let (self_weight, other_weight) = (self.weight(), other.weight());
-        let total_weight = self_weight + other_weight;
-        self.weight = self
-            .weight
-            .checked_add(other.weight.get())
-            .expect("weight overflow");
 
-        // Start at the heavier centroid so a small contribution is not 
rounded away in 1 - ratio.
-        let (start, end, fraction) = if self_weight >= other_weight {
-            (self.mean, other.mean, other_weight / total_weight)
-        } else {
-            (other.mean, self.mean, self_weight / total_weight)
-        };
-        debug_assert!(start.is_finite() && end.is_finite() && fraction > 0. && 
fraction <= 0.5);
+    // compatibility with the format of the reference implementation
+    // default byte order of ByteBuffer is used there, which is big endian
+    fn deserialize_compat(bytes: &[u8]) -> Result<Self, Error> {
+        fn make_error(tag: &'static str) -> impl FnOnce(std::io::Error) -> 
Error {
+            move |_| Error::insufficient_data_of("compat format", tag)
+        }
 
-        // Same-sign means within a factor of two in magnitude subtract 
exactly. For wider
-        // gaps, a step of at most 1/2 cannot cross either endpoint even if 
subtraction rounds.
-        // Queries can interpolate farther and still need the general bounds.
-        self.mean = if start.is_sign_positive() == end.is_sign_positive() {
-            (end - start).mul_add(fraction, start)
-        } else if 1. - fraction == 1. {
-            // Both corrections can matter even when 1 - fraction rounds to 1.
-            // Halving before subtraction avoids overflow; the FMA combines the
-            // correction with start before rounding the result.
-            (end * 0.5 - start * 0.5).mul_add(2. * fraction, start)
-        } else {
-            interpolate(start, end, fraction)
-        };
-    }
+        let mut cursor = SketchSlice::new(bytes);
 
-    fn weight(&self) -> f64 {
-        self.weight.get() as f64
+        let ty = cursor.read_u32_be().map_err(make_error("type"))?;
+        match ty {
+            COMPAT_DOUBLE => {
+                fn make_error(tag: &'static str) -> impl 
FnOnce(std::io::Error) -> Error {
+                    move |_| Error::insufficient_data_of("compat double 
format", tag)
+                }
+                // compatibility with asBytes()
+                let min = cursor.read_f64_be().map_err(make_error("min"))?;
+                let max = cursor.read_f64_be().map_err(make_error("max"))?;
+                check_extrema(min, max, "compat double TDigest")?;
+                let k = cursor.read_f64_be().map_err(make_error("k"))? as u16;
+                if k < 10 {
+                    return Err(Error::deserial(format!(
+                        "k must be at least 10 in compat double format, got 
{k}"
+                    )));
+                }
+                let num_centroids =
+                    cursor.read_u32_be().map_err(make_error("num_centroids"))? 
as usize;
+                if num_centroids == 0 {
+                    return Err(Error::deserial(
+                        "malformed data: compat double TDigest must contain a 
centroid",
+                    ));
+                }
+                let mut total_weight = 0u64;
+                let mut centroids = Vec::with_capacity(num_centroids);
+                let mut previous_mean = min;
+                let mut centroid_means_valid = true;
+                for _ in 0..num_centroids {
+                    let weight = 
cursor.read_f64_be().map_err(make_error("weight"))?;
+                    let mean = 
cursor.read_f64_be().map_err(make_error("mean"))?;
+                    let weight =
+                        check_compat_weight(weight, "centroid weight in compat 
double format")?;
+                    centroid_means_valid &=
+                        mean.is_finite() & (mean >= previous_mean) & (mean <= 
max);
+                    previous_mean = mean;
+                    total_weight = checked_weight_sum(total_weight, 
weight.get())?;
+                    centroids.push(Centroid { mean, weight });
+                }
+                if !centroid_means_valid {
+                    return Err(Error::deserial(
+                        "malformed data: centroid means in compat double 
format must be finite, within extrema, and nondecreasing",
+                    ));
+                }
+                check_single_sample_extrema(min, max, total_weight)?;
+                Ok(TDigestMut::from_parts(
+                    k,
+                    false,
+                    min,
+                    max,
+                    TDigestBuffer::new(centroids, 0),
+                    total_weight,
+                ))
+            }
+            COMPAT_FLOAT => {
+                fn make_error(tag: &'static str) -> impl 
FnOnce(std::io::Error) -> Error {
+                    move |_| Error::insufficient_data_of("compat float 
format", tag)
+                }
+                // COMPAT_FLOAT: compatibility with asSmallBytes()
+                // reference implementation uses doubles for min and max
+                let min = cursor.read_f64_be().map_err(make_error("min"))?;
+                let max = cursor.read_f64_be().map_err(make_error("max"))?;
+                check_extrema(min, max, "compat float TDigest")?;
+                let k = cursor.read_f32_be().map_err(make_error("k"))? as u16;
+                if k < 10 {
+                    return Err(Error::deserial(format!(
+                        "k must be at least 10 in compat float format, got {k}"
+                    )));
+                }
+                // reference implementation stores capacities of the array of 
centroids and the
+                // buffer as shorts they can be derived from k in the 
constructor
+                cursor.read_u32_be().map_err(make_error("<unused>"))?;
+                let num_centroids =
+                    cursor.read_u16_be().map_err(make_error("num_centroids"))? 
as usize;
+                if num_centroids == 0 {
+                    return Err(Error::deserial(
+                        "malformed data: compat float TDigest must contain a 
centroid",
+                    ));
+                }
+                let mut total_weight = 0u64;
+                let mut centroids = Vec::with_capacity(num_centroids);
+                let mut previous_mean = min;
+                let mut centroid_means_valid = true;
+                for _ in 0..num_centroids {
+                    let weight = 
cursor.read_f32_be().map_err(make_error("weight"))? as f64;
+                    let mean = 
cursor.read_f32_be().map_err(make_error("mean"))? as f64;
+                    let weight =
+                        check_compat_weight(weight, "centroid weight in compat 
float format")?;
+                    centroid_means_valid &=
+                        mean.is_finite() & (mean >= previous_mean) & (mean <= 
max);
+                    previous_mean = mean;
+                    total_weight = checked_weight_sum(total_weight, 
weight.get())?;
+                    centroids.push(Centroid { mean, weight });
+                }
+                if !centroid_means_valid {
+                    return Err(Error::deserial(
+                        "malformed data: centroid means in compat float format 
must be finite, within extrema, and nondecreasing",
+                    ));
+                }
+                check_single_sample_extrema(min, max, total_weight)?;
+                Ok(TDigestMut::from_parts(
+                    k,
+                    false,
+                    min,
+                    max,
+                    TDigestBuffer::new(centroids, 0),
+                    total_weight,
+                ))
+            }
+            ty => Err(Error::deserial(format!("unknown TDigest compat type 
{ty}"))),
+        }
     }
 }
 
@@ -2242,71 +2314,6 @@ fn checked_weight_sum(total_weight: u64, weight: u64) -> 
Result<u64, Error> {
         .ok_or_else(|| Error::deserial("malformed data: total weight 
overflow"))
 }
 
-/// Generates cluster sizes proportional to `q*(1-q)`.
-///
-/// The use of a normalizing function results in a strictly bounded number of 
clusters no matter
-/// how many samples.
-///
-/// Corresponds to K_2 in the reference implementation
-mod scale_function {
-    pub fn max(q: f64, normalizer: f64) -> f64 {
-        q * (1. - q) / normalizer
-    }
-
-    pub fn normalizer(compression: f64, n: f64) -> f64 {
-        compression / z(compression, n)
-    }
-
-    pub fn z(compression: f64, n: f64) -> f64 {
-        4. * (n / compression).ln() + 24.
-    }
-}
-
-/// Keeps cumulative counts exact until a centroid's midpoint is needed for 
interpolation.
-/// All intervals and tails use this same center to avoid inconsistent 
boundary rounding.
-fn centroid_center(weight_before: u64, weight: u64) -> f64 {
-    (weight_before + weight / 2) as f64 + (weight % 2) as f64 * 0.5
-}
-
-/// Interpolates finite endpoints at a fraction in `[0, 1]`, preserving their 
bounds.
-///
-/// The sign split follows WG21 P0811R3:
-/// <https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p0811r3.html>.
-fn interpolate(start: f64, end: f64, fraction: f64) -> f64 {
-    debug_assert!(start.is_finite() && end.is_finite() && 
(0.0..=1.0).contains(&fraction));
-    // Callers guarantee the fraction's range, but division may round it to an 
endpoint.
-    // Even an FMA need not reproduce `end` at 1: its subtracted difference 
already rounded.
-    if fraction <= 0. {
-        return start;
-    }
-    if fraction >= 1. {
-        return end;
-    }
-    if start.is_sign_positive() != end.is_sign_positive() {
-        // Avoid the potentially overflowing difference of opposite-signed 
endpoints.
-        start * (1. - fraction) + end * fraction
-    } else {
-        // Same-sign subtraction is finite; rounding must not overshoot the 
end.
-        let value = (end - start).mul_add(fraction, start);
-        if start < end {
-            value.min(end)
-        } else {
-            value.max(end)
-        }
-    }
-}
-
-/// Locates a value between distinct finite endpoints.
-fn interpolation_fraction(value: f64, start: f64, end: f64) -> f64 {
-    let width = end - start;
-    if width.is_finite() {
-        (value - start) / width
-    } else {
-        // Scaling is needed only when opposite-signed endpoints overflow 
their difference.
-        (value * 0.5 - start * 0.5) / (end * 0.5 - start * 0.5)
-    }
-}
-
 #[cfg(test)]
 mod tests {
     use super::interpolate;


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to