Skip to main content

datafusion_functions_aggregate_common/
tdigest.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18//! An implementation of the [TDigest sketch algorithm] providing approximate
19//! quantile calculations.
20//!
21//! The TDigest code in this module is modified from
22//! <https://github.com/MnO2/t-digest>, itself a rust reimplementation of
23//! [Facebook's Folly TDigest] implementation.
24//!
25//! Alterations include reduction of runtime heap allocations, broader type
26//! support, (de-)serialization support, reduced type conversions and null value
27//! tolerance.
28//!
29//! [TDigest sketch algorithm]: https://arxiv.org/abs/1902.04023
30//! [Facebook's Folly TDigest]: https://github.com/facebook/folly/blob/main/folly/stats/TDigest.h
31
32use arrow::datatypes::DataType;
33use arrow::datatypes::Float64Type;
34use datafusion_common::cast::as_primitive_array;
35use datafusion_common::{DataFusionError, ScalarValue, exec_err};
36use std::cmp::Ordering;
37use std::mem::{size_of, size_of_val};
38
39pub const DEFAULT_MAX_SIZE: usize = 100;
40
41// Cast a non-null [`ScalarValue::Float64`] to an [`f64`], or
42// panic.
43macro_rules! cast_scalar_f64 {
44    ($value:expr ) => {
45        match &$value {
46            ScalarValue::Float64(Some(v)) => *v,
47            v => panic!("invalid type {}", v),
48        }
49    };
50}
51
52/// Centroid implementation to the cluster mentioned in the paper.
53#[derive(Debug, PartialEq, Clone)]
54pub struct Centroid {
55    mean: f64,
56    weight: f64,
57}
58
59impl Centroid {
60    pub fn new(mean: f64, weight: f64) -> Self {
61        Centroid { mean, weight }
62    }
63
64    #[inline]
65    pub fn mean(&self) -> f64 {
66        self.mean
67    }
68
69    #[inline]
70    pub fn weight(&self) -> f64 {
71        self.weight
72    }
73
74    pub fn add(&mut self, sum: f64, weight: f64) -> f64 {
75        let new_sum = sum + self.weight * self.mean;
76        let new_weight = self.weight + weight;
77        self.weight = new_weight;
78        self.mean = new_sum / new_weight;
79        new_sum
80    }
81
82    pub fn cmp_mean(&self, other: &Self) -> Ordering {
83        self.mean.total_cmp(&other.mean)
84    }
85}
86
87impl Default for Centroid {
88    fn default() -> Self {
89        Centroid {
90            mean: 0_f64,
91            weight: 1_f64,
92        }
93    }
94}
95
96/// T-Digest to be operated on.
97#[derive(Debug, PartialEq, Clone)]
98pub struct TDigest {
99    centroids: Vec<Centroid>,
100    max_size: usize,
101    sum: f64,
102    count: f64,
103    max: f64,
104    min: f64,
105}
106
107impl TDigest {
108    pub fn new(max_size: usize) -> Self {
109        TDigest {
110            centroids: Vec::new(),
111            max_size,
112            sum: 0.0,
113            count: 0.0,
114            max: f64::NAN,
115            min: f64::NAN,
116        }
117    }
118
119    #[expect(clippy::needless_pass_by_value)]
120    pub fn new_with_centroid(max_size: usize, centroid: Centroid) -> Self {
121        TDigest {
122            centroids: vec![centroid.clone()],
123            max_size,
124            sum: centroid.mean * centroid.weight,
125            count: centroid.weight,
126            max: centroid.mean,
127            min: centroid.mean,
128        }
129    }
130
131    #[inline]
132    pub fn count(&self) -> f64 {
133        self.count
134    }
135
136    #[inline]
137    pub fn max(&self) -> f64 {
138        self.max
139    }
140
141    #[inline]
142    pub fn min(&self) -> f64 {
143        self.min
144    }
145
146    #[inline]
147    pub fn max_size(&self) -> usize {
148        self.max_size
149    }
150
151    /// The sum of all values ingested into this digest.
152    #[inline]
153    pub fn sum(&self) -> f64 {
154        self.sum
155    }
156
157    /// The centroids that make up this digest, ordered by mean.
158    ///
159    /// Together with the [`Self::sum()`], [`Self::max_size()`],
160    /// [`Self::count()`], [`Self::max()`], and [`Self::min()`] accessors this
161    /// exposes the full serialized state of the digest without packing it into
162    /// a [`ScalarValue`] list. See [`Self::try_from_parts()`] for the inverse.
163    #[inline]
164    pub fn centroids(&self) -> &[Centroid] {
165        &self.centroids
166    }
167
168    /// Size in bytes including `Self`.
169    pub fn size(&self) -> usize {
170        size_of_val(self) + (size_of::<Centroid>() * self.centroids.capacity())
171    }
172}
173
174impl Default for TDigest {
175    fn default() -> Self {
176        TDigest {
177            centroids: Vec::new(),
178            max_size: 100,
179            sum: 0.0,
180            count: 0.0,
181            max: f64::NAN,
182            min: f64::NAN,
183        }
184    }
185}
186
187impl TDigest {
188    fn k_to_q(k: u64, d: usize) -> f64 {
189        let k_div_d = k as f64 / d as f64;
190        if k_div_d >= 0.5 {
191            let base = 1.0 - k_div_d;
192            1.0 - 2.0 * base * base
193        } else {
194            2.0 * k_div_d * k_div_d
195        }
196    }
197
198    fn clamp(v: f64, lo: f64, hi: f64) -> f64 {
199        if lo.is_nan() || hi.is_nan() {
200            return v;
201        }
202
203        // Handle the case where floating point precision causes min > max.
204        let (min, max) = if lo > hi { (hi, lo) } else { (lo, hi) };
205
206        v.clamp(min, max)
207    }
208
209    // public for testing in other modules
210    pub fn merge_unsorted_f64(&self, unsorted_values: Vec<f64>) -> TDigest {
211        let mut values = unsorted_values;
212        values.sort_by(|a, b| a.total_cmp(b));
213        self.merge_sorted_f64(&values)
214    }
215
216    pub fn merge_sorted_f64(&self, sorted_values: &[f64]) -> TDigest {
217        #[cfg(debug_assertions)]
218        debug_assert!(is_sorted(sorted_values), "unsorted input to TDigest");
219
220        if sorted_values.is_empty() {
221            return self.clone();
222        }
223
224        let mut result = TDigest::new(self.max_size());
225        result.count = self.count() + sorted_values.len() as f64;
226
227        let maybe_min = *sorted_values.first().unwrap();
228        let maybe_max = *sorted_values.last().unwrap();
229
230        if self.count() > 0.0 {
231            result.min = self.min.min(maybe_min);
232            result.max = self.max.max(maybe_max);
233        } else {
234            result.min = maybe_min;
235            result.max = maybe_max;
236        }
237
238        let mut compressed: Vec<Centroid> = Vec::with_capacity(self.max_size);
239
240        let mut k_limit: u64 = 1;
241        let mut q_limit_times_count =
242            Self::k_to_q(k_limit, self.max_size) * result.count();
243        k_limit += 1;
244
245        let mut iter_centroids = self.centroids.iter().peekable();
246        let mut iter_sorted_values = sorted_values.iter().peekable();
247
248        let mut curr: Centroid = if let Some(c) = iter_centroids.peek() {
249            let curr = **iter_sorted_values.peek().unwrap();
250            if c.mean() < curr {
251                iter_centroids.next().unwrap().clone()
252            } else {
253                Centroid::new(*iter_sorted_values.next().unwrap(), 1.0)
254            }
255        } else {
256            Centroid::new(*iter_sorted_values.next().unwrap(), 1.0)
257        };
258
259        let mut weight_so_far = curr.weight();
260
261        let mut sums_to_merge = 0_f64;
262        let mut weights_to_merge = 0_f64;
263
264        while iter_centroids.peek().is_some() || iter_sorted_values.peek().is_some() {
265            let next: Centroid = if let Some(c) = iter_centroids.peek() {
266                if iter_sorted_values.peek().is_none()
267                    || c.mean() < **iter_sorted_values.peek().unwrap()
268                {
269                    iter_centroids.next().unwrap().clone()
270                } else {
271                    Centroid::new(*iter_sorted_values.next().unwrap(), 1.0)
272                }
273            } else {
274                Centroid::new(*iter_sorted_values.next().unwrap(), 1.0)
275            };
276
277            let next_sum = next.mean() * next.weight();
278            weight_so_far += next.weight();
279
280            if weight_so_far <= q_limit_times_count {
281                sums_to_merge += next_sum;
282                weights_to_merge += next.weight();
283            } else {
284                result.sum += curr.add(sums_to_merge, weights_to_merge);
285                sums_to_merge = 0_f64;
286                weights_to_merge = 0_f64;
287
288                compressed.push(curr.clone());
289                q_limit_times_count =
290                    Self::k_to_q(k_limit, self.max_size) * result.count();
291                k_limit += 1;
292                curr = next;
293            }
294        }
295
296        result.sum += curr.add(sums_to_merge, weights_to_merge);
297        compressed.push(curr);
298        compressed.shrink_to_fit();
299        compressed.sort_by(|a, b| a.cmp_mean(b));
300
301        result.centroids = compressed;
302        result
303    }
304
305    fn external_merge(
306        centroids: &mut [Centroid],
307        first: usize,
308        middle: usize,
309        last: usize,
310    ) {
311        let mut result: Vec<Centroid> = Vec::with_capacity(centroids.len());
312
313        let mut i = first;
314        let mut j = middle;
315
316        while i < middle && j < last {
317            match centroids[i].cmp_mean(&centroids[j]) {
318                Ordering::Less => {
319                    result.push(centroids[i].clone());
320                    i += 1;
321                }
322                Ordering::Greater => {
323                    result.push(centroids[j].clone());
324                    j += 1;
325                }
326                Ordering::Equal => {
327                    result.push(centroids[i].clone());
328                    i += 1;
329                }
330            }
331        }
332
333        while i < middle {
334            result.push(centroids[i].clone());
335            i += 1;
336        }
337
338        while j < last {
339            result.push(centroids[j].clone());
340            j += 1;
341        }
342
343        i = first;
344        for centroid in result.into_iter() {
345            centroids[i] = centroid;
346            i += 1;
347        }
348    }
349
350    // Merge multiple T-Digests
351    pub fn merge_digests<'a>(digests: impl IntoIterator<Item = &'a TDigest>) -> TDigest {
352        let digests = digests.into_iter().collect::<Vec<_>>();
353        let n_centroids: usize = digests.iter().map(|d| d.centroids.len()).sum();
354        if n_centroids == 0 {
355            return TDigest::default();
356        }
357
358        let max_size = digests.first().unwrap().max_size;
359        let mut centroids: Vec<Centroid> = Vec::with_capacity(n_centroids);
360        let mut starts: Vec<usize> = Vec::with_capacity(digests.len());
361
362        let mut count = 0.0;
363        let mut min = f64::INFINITY;
364        let mut max = f64::NEG_INFINITY;
365
366        let mut start: usize = 0;
367        for digest in digests.iter() {
368            starts.push(start);
369
370            let curr_count = digest.count();
371            if curr_count > 0.0 {
372                min = min.min(digest.min);
373                max = max.max(digest.max);
374                count += curr_count;
375                for centroid in &digest.centroids {
376                    centroids.push(centroid.clone());
377                    start += 1;
378                }
379            }
380        }
381
382        // If no centroids were added (all digests had zero count), return default
383        if centroids.is_empty() {
384            return TDigest::default();
385        }
386
387        let mut digests_per_block: usize = 1;
388        while digests_per_block < starts.len() {
389            for i in (0..starts.len()).step_by(digests_per_block * 2) {
390                if i + digests_per_block < starts.len() {
391                    let first = starts[i];
392                    let middle = starts[i + digests_per_block];
393                    let last = if i + 2 * digests_per_block < starts.len() {
394                        starts[i + 2 * digests_per_block]
395                    } else {
396                        centroids.len()
397                    };
398
399                    debug_assert!(first <= middle && middle <= last);
400                    Self::external_merge(&mut centroids, first, middle, last);
401                }
402            }
403
404            digests_per_block *= 2;
405        }
406
407        let mut result = TDigest::new(max_size);
408        let mut compressed: Vec<Centroid> = Vec::with_capacity(max_size);
409
410        let mut k_limit = 1;
411        let mut q_limit_times_count = Self::k_to_q(k_limit, max_size) * count;
412
413        let mut iter_centroids = centroids.iter_mut();
414        let mut curr = iter_centroids.next().unwrap();
415        let mut weight_so_far = curr.weight();
416        let mut sums_to_merge = 0_f64;
417        let mut weights_to_merge = 0_f64;
418
419        for centroid in iter_centroids {
420            weight_so_far += centroid.weight();
421
422            if weight_so_far <= q_limit_times_count {
423                sums_to_merge += centroid.mean() * centroid.weight();
424                weights_to_merge += centroid.weight();
425            } else {
426                result.sum += curr.add(sums_to_merge, weights_to_merge);
427                sums_to_merge = 0_f64;
428                weights_to_merge = 0_f64;
429                compressed.push(curr.clone());
430                q_limit_times_count = Self::k_to_q(k_limit, max_size) * count;
431                k_limit += 1;
432                curr = centroid;
433            }
434        }
435
436        result.sum += curr.add(sums_to_merge, weights_to_merge);
437        compressed.push(curr.clone());
438        compressed.shrink_to_fit();
439        compressed.sort_by(|a, b| a.cmp_mean(b));
440
441        result.count = count;
442        result.min = min;
443        result.max = max;
444        result.centroids = compressed;
445        result
446    }
447
448    /// To estimate the value located at `q` quantile
449    pub fn estimate_quantile(&self, q: f64) -> f64 {
450        if self.centroids.is_empty() {
451            return 0.0;
452        }
453
454        let rank = q * self.count;
455
456        let mut pos: usize;
457        let mut t;
458        if q > 0.5 {
459            if q >= 1.0 {
460                return self.max();
461            }
462
463            pos = 0;
464            t = self.count;
465
466            for (k, centroid) in self.centroids.iter().enumerate().rev() {
467                t -= centroid.weight();
468
469                if rank >= t {
470                    pos = k;
471                    break;
472                }
473            }
474        } else {
475            if q <= 0.0 {
476                return self.min();
477            }
478
479            pos = self.centroids.len() - 1;
480            t = 0_f64;
481
482            for (k, centroid) in self.centroids.iter().enumerate() {
483                if rank < t + centroid.weight() {
484                    pos = k;
485                    break;
486                }
487
488                t += centroid.weight();
489            }
490        }
491
492        let mut delta = 0_f64;
493        let mut min = self.min;
494        let mut max = self.max;
495
496        if self.centroids.len() > 1 {
497            if pos == 0 {
498                delta = self.centroids[pos + 1].mean() - self.centroids[pos].mean();
499                max = self.centroids[pos + 1].mean();
500            } else if pos == (self.centroids.len() - 1) {
501                delta = self.centroids[pos].mean() - self.centroids[pos - 1].mean();
502                min = self.centroids[pos - 1].mean();
503            } else {
504                delta = (self.centroids[pos + 1].mean() - self.centroids[pos - 1].mean())
505                    / 2.0;
506                min = self.centroids[pos - 1].mean();
507                max = self.centroids[pos + 1].mean();
508            }
509        }
510
511        let value = self.centroids[pos].mean()
512            + ((rank - t) / self.centroids[pos].weight() - 0.5) * delta;
513
514        // In `merge_digests()`: `min` is initialized to Inf, `max` is initialized to -Inf
515        // and gets updated according to different `TDigest`s
516        // However, `min`/`max` won't get updated if there is only one `NaN` within `TDigest`
517        // The following two checks is for such edge case
518        if !min.is_finite() && min.is_sign_positive() {
519            min = f64::NEG_INFINITY;
520        }
521
522        if !max.is_finite() && max.is_sign_negative() {
523            max = f64::INFINITY;
524        }
525
526        Self::clamp(value, min, max)
527    }
528
529    /// This method decomposes the [`TDigest`] and its [`Centroid`] instances
530    /// into a series of primitive scalar values.
531    ///
532    /// First the values of the TDigest are packed, followed by the variable
533    /// number of centroids packed into a [`ScalarValue::List`] of
534    /// [`ScalarValue::Float64`]:
535    ///
536    /// ```text
537    ///
538    ///    ┌────────┬────────┬────────┬───────┬────────┬────────┐
539    ///    │max_size│  sum   │ count  │  max  │  min   │centroid│
540    ///    └────────┴────────┴────────┴───────┴────────┴────────┘
541    ///                                                     │
542    ///                               ┌─────────────────────┘
543    ///                               ▼
544    ///                          ┌ List ───┐
545    ///                          │┌ ─ ─ ─ ┐│
546    ///                          │  mean   │
547    ///                          │├ ─ ─ ─ ┼│─ ─ Centroid 1
548    ///                          │ weight  │
549    ///                          │└ ─ ─ ─ ┘│
550    ///                          │         │
551    ///                          │┌ ─ ─ ─ ┐│
552    ///                          │  mean   │
553    ///                          │├ ─ ─ ─ ┼│─ ─ Centroid 2
554    ///                          │ weight  │
555    ///                          │└ ─ ─ ─ ┘│
556    ///                          │         │
557    ///                              ...
558    /// ```
559    ///
560    /// The [`TDigest::from_scalar_state()`] method reverses this processes,
561    /// consuming the output of this method and returning an unpacked
562    /// [`TDigest`].
563    pub fn to_scalar_state(&self) -> Vec<ScalarValue> {
564        // Gather up all the centroids
565        let centroids: Vec<ScalarValue> = self
566            .centroids
567            .iter()
568            .flat_map(|c| [c.mean(), c.weight()])
569            .map(|v| ScalarValue::Float64(Some(v)))
570            .collect();
571
572        let arr = ScalarValue::new_list_nullable(&centroids, &DataType::Float64);
573
574        vec![
575            ScalarValue::UInt64(Some(self.max_size as u64)),
576            ScalarValue::Float64(Some(self.sum)),
577            ScalarValue::Float64(Some(self.count)),
578            ScalarValue::Float64(Some(self.max)),
579            ScalarValue::Float64(Some(self.min)),
580            ScalarValue::List(arr),
581        ]
582    }
583
584    /// Unpack the serialized state of a [`TDigest`] produced by
585    /// [`Self::to_scalar_state()`].
586    ///
587    /// # Correctness
588    ///
589    /// Providing input to this method that was not obtained from
590    /// [`Self::to_scalar_state()`] results in undefined behaviour and may
591    /// panic.
592    pub fn from_scalar_state(state: &[ScalarValue]) -> Self {
593        assert_eq!(state.len(), 6, "invalid TDigest state");
594
595        let max_size = match &state[0] {
596            ScalarValue::UInt64(Some(v)) => *v as usize,
597            v => panic!("invalid max_size type {v:?}"),
598        };
599
600        let centroids: Vec<_> = match &state[5] {
601            ScalarValue::List(arr) => {
602                let array = arr.values();
603
604                let f64arr =
605                    as_primitive_array::<Float64Type>(array).expect("expected f64 array");
606                f64arr
607                    .values()
608                    .chunks(2)
609                    .map(|v| Centroid::new(v[0], v[1]))
610                    .collect()
611            }
612            v => panic!("invalid centroids type {v:?}"),
613        };
614
615        let max = cast_scalar_f64!(&state[3]);
616        let min = cast_scalar_f64!(&state[4]);
617
618        if min.is_finite() && max.is_finite() {
619            assert!(max.total_cmp(&min).is_ge());
620        }
621
622        Self {
623            max_size,
624            sum: cast_scalar_f64!(state[1]),
625            count: cast_scalar_f64!(state[2]),
626            max,
627            min,
628            centroids,
629        }
630    }
631
632    /// Construct a [`TDigest`] directly from its constituent parts, validating
633    /// the inputs.
634    ///
635    /// Together with the [`Self::centroids()`], [`Self::sum()`],
636    /// [`Self::max_size()`], [`Self::count()`], [`Self::max()`], and
637    /// [`Self::min()`] accessors, this allows a digest to be serialized into and
638    /// restored from a caller's own format without round-tripping through a
639    /// [`ScalarValue`] list (the non-Arrow counterpart to
640    /// [`Self::from_scalar_state()`]).
641    ///
642    /// Unlike [`Self::from_scalar_state()`], this validates its inputs, returning
643    /// an error rather than a silently wrong digest when handed corrupt state.
644    /// Callers who trust their data can `unwrap()`.
645    ///
646    /// # Errors
647    ///
648    /// Returns an error if:
649    /// - `min` and `max` are both finite but `max < min`;
650    /// - the `centroids` are not sorted in non-decreasing order by mean (the
651    ///   order produced by [`Self::centroids()`]); or
652    /// - any centroid weight is not finite and strictly positive
653    ///   ([`Self::estimate_quantile()`] divides by a centroid's weight, so a
654    ///   zero, negative, or non-finite weight yields silently wrong results).
655    pub fn try_from_parts(
656        max_size: usize,
657        sum: f64,
658        count: f64,
659        max: f64,
660        min: f64,
661        centroids: Vec<Centroid>,
662    ) -> Result<Self, DataFusionError> {
663        if min.is_finite() && max.is_finite() && max.total_cmp(&min).is_lt() {
664            return exec_err!(
665                "invalid TDigest state: max ({max}) is less than min ({min})"
666            );
667        }
668
669        for pair in centroids.windows(2) {
670            if pair[0].cmp_mean(&pair[1]).is_gt() {
671                return exec_err!(
672                    "invalid TDigest state: centroids must be sorted by mean, \
673                     but {} precedes {}",
674                    pair[0].mean(),
675                    pair[1].mean()
676                );
677            }
678        }
679
680        for centroid in &centroids {
681            if !(centroid.weight().is_finite() && centroid.weight() > 0.0) {
682                return exec_err!(
683                    "invalid TDigest state: centroid weight must be finite and \
684                     positive, got {}",
685                    centroid.weight()
686                );
687            }
688        }
689
690        Ok(Self {
691            max_size,
692            sum,
693            count,
694            max,
695            min,
696            centroids,
697        })
698    }
699}
700
701#[cfg(debug_assertions)]
702fn is_sorted(values: &[f64]) -> bool {
703    values.windows(2).all(|w| w[0].total_cmp(&w[1]).is_le())
704}
705
706#[cfg(test)]
707mod tests {
708    use super::*;
709
710    // A macro to assert the specified `quantile` estimated by `t` is within the
711    // allowable relative error bound.
712    macro_rules! assert_error_bounds {
713        ($t:ident, quantile = $quantile:literal, want = $want:literal) => {
714            assert_error_bounds!(
715                $t,
716                quantile = $quantile,
717                want = $want,
718                allowable_error = 0.01
719            )
720        };
721        ($t:ident, quantile = $quantile:literal, want = $want:literal, allowable_error = $re:literal) => {
722            let ans = $t.estimate_quantile($quantile);
723            let expected: f64 = $want;
724            let percentage: f64 = (expected - ans).abs() / expected;
725            assert!(
726                percentage < $re,
727                "relative error {} is more than {}% (got quantile {}, want {})",
728                percentage,
729                $re,
730                ans,
731                expected
732            );
733        };
734    }
735
736    macro_rules! assert_state_roundtrip {
737        ($t:ident) => {
738            let state = $t.to_scalar_state();
739            let other = TDigest::from_scalar_state(&state);
740            assert_eq!($t, other);
741        };
742    }
743
744    #[test]
745    fn test_int64_uniform() {
746        let values = (1i64..=1000).map(|v| v as f64).collect();
747
748        let t = TDigest::new(100);
749        let t = t.merge_unsorted_f64(values);
750
751        assert_error_bounds!(t, quantile = 0.1, want = 100.0);
752        assert_error_bounds!(t, quantile = 0.5, want = 500.0);
753        assert_error_bounds!(t, quantile = 0.9, want = 900.0);
754        assert_state_roundtrip!(t);
755    }
756
757    #[test]
758    fn test_centroid_addition_regression() {
759        // https://github.com/MnO2/t-digest/pull/1
760
761        let vals = vec![1.0, 1.0, 1.0, 2.0, 1.0, 1.0];
762        let mut t = TDigest::new(10);
763
764        for v in vals {
765            t = t.merge_unsorted_f64(vec![v]);
766        }
767
768        assert_error_bounds!(t, quantile = 0.5, want = 1.0);
769        assert_error_bounds!(t, quantile = 0.95, want = 2.0);
770        assert_state_roundtrip!(t);
771    }
772
773    #[test]
774    fn test_merge_unsorted_against_uniform_distro() {
775        let t = TDigest::new(100);
776        let values: Vec<_> = (1..=1_000_000).map(f64::from).collect();
777
778        let t = t.merge_unsorted_f64(values);
779
780        assert_error_bounds!(t, quantile = 1.0, want = 1_000_000.0);
781        assert_error_bounds!(t, quantile = 0.99, want = 990_000.0);
782        assert_error_bounds!(t, quantile = 0.01, want = 10_000.0);
783        assert_error_bounds!(t, quantile = 0.0, want = 1.0);
784        assert_error_bounds!(t, quantile = 0.5, want = 500_000.0);
785        assert_state_roundtrip!(t);
786    }
787
788    #[test]
789    fn test_merge_unsorted_against_skewed_distro() {
790        let t = TDigest::new(100);
791        let mut values: Vec<_> = (1..=600_000).map(f64::from).collect();
792        values.resize(1_000_000, 1_000_000_f64);
793
794        let t = t.merge_unsorted_f64(values);
795
796        assert_error_bounds!(t, quantile = 0.99, want = 1_000_000.0);
797        assert_error_bounds!(t, quantile = 0.01, want = 10_000.0);
798        assert_error_bounds!(t, quantile = 0.5, want = 500_000.0);
799        assert_state_roundtrip!(t);
800    }
801
802    #[test]
803    fn test_merge_digests() {
804        let mut digests: Vec<TDigest> = Vec::new();
805
806        for _ in 1..=100 {
807            let t = TDigest::new(100);
808            let values: Vec<_> = (1..=1_000).map(f64::from).collect();
809            let t = t.merge_unsorted_f64(values);
810            digests.push(t)
811        }
812
813        let t = TDigest::merge_digests(&digests);
814
815        assert_error_bounds!(t, quantile = 1.0, want = 1000.0);
816        assert_error_bounds!(t, quantile = 0.99, want = 990.0);
817        assert_error_bounds!(t, quantile = 0.01, want = 10.0, allowable_error = 0.2);
818        assert_error_bounds!(t, quantile = 0.0, want = 1.0);
819        assert_error_bounds!(t, quantile = 0.5, want = 500.0);
820        assert_state_roundtrip!(t);
821    }
822
823    #[test]
824    fn test_size() {
825        let t = TDigest::new(10);
826        let t = t.merge_unsorted_f64(vec![0.0, 1.0]);
827
828        assert_eq!(t.size(), 96);
829    }
830
831    #[test]
832    fn test_identical_values_floating_point_precision() {
833        // Regression test for https://github.com/apache/datafusion/issues/14855
834        // When all values are the same, floating-point arithmetic during centroid
835        // merging can cause slight precision differences between min and max,
836        // which previously caused a panic in clamp().
837
838        let t = TDigest::new(100);
839        let values: Vec<_> = (0..215).map(|_| 15.699999988079073_f64).collect();
840
841        let t = t.merge_unsorted_f64(values);
842
843        // This should not panic
844        let result = t.estimate_quantile(0.99);
845        // The result should be approximately equal to the input value
846        assert!((result - 15.699999988079073).abs() < 1e-10);
847    }
848
849    // A representative set of digests covering the empty, single-value and
850    // heavily-compressed cases, used to exercise the `try_from_parts`/accessor
851    // external-state contract.
852    fn sample_digests() -> Vec<TDigest> {
853        vec![
854            // Empty: no values ingested, so max/min are NaN and centroids empty.
855            TDigest::new(100),
856            // A single value.
857            TDigest::new(100).merge_unsorted_f64(vec![42.0]),
858            // Many values, forcing compression down to `max_size` centroids.
859            TDigest::new(100).merge_unsorted_f64((1..=10_000).map(f64::from).collect()),
860            // A different shape and `max_size`.
861            TDigest::new(50)
862                .merge_unsorted_f64((1..=5_000).map(|v| f64::from(v).sqrt()).collect()),
863        ]
864    }
865
866    const QUANTILE_GRID: [f64; 9] = [0.0, 0.01, 0.1, 0.25, 0.5, 0.75, 0.9, 0.99, 1.0];
867
868    // Rebuild a digest purely from its public accessors via `try_from_parts`.
869    fn rebuild_via_parts(t: &TDigest) -> TDigest {
870        TDigest::try_from_parts(
871            t.max_size(),
872            t.sum(),
873            t.count(),
874            t.max(),
875            t.min(),
876            t.centroids().to_vec(),
877        )
878        .expect("digest built from real accessors is valid")
879    }
880
881    #[test]
882    fn test_from_parts_roundtrip() {
883        for t in sample_digests() {
884            let rebuilt = rebuild_via_parts(&t);
885
886            // The serialized state must be identical. `to_scalar_state()`
887            // compares `Float64` by bit pattern, so this also holds for the
888            // empty digest whose max/min are NaN.
889            assert_eq!(rebuilt.to_scalar_state(), t.to_scalar_state());
890
891            // Quantile estimates must be bitwise-equal across the grid.
892            for q in QUANTILE_GRID {
893                assert_eq!(
894                    rebuilt.estimate_quantile(q).to_bits(),
895                    t.estimate_quantile(q).to_bits(),
896                    "quantile {q} diverged after try_from_parts roundtrip"
897                );
898            }
899        }
900    }
901
902    #[test]
903    fn test_from_parts_equals_original() {
904        // For digests without NaN fields, use the strongest available equality:
905        // the derived `PartialEq` on `TDigest`. (The empty digest is excluded
906        // because NaN != NaN under the derived comparison; it is covered by
907        // `test_from_parts_roundtrip` via `to_scalar_state`.)
908        for t in sample_digests().into_iter().filter(|t| t.count() > 0.0) {
909            let rebuilt = rebuild_via_parts(&t);
910            assert_eq!(rebuilt, t);
911        }
912    }
913
914    #[test]
915    fn test_accessors_agree_with_scalar_state() {
916        for t in sample_digests() {
917            let state = t.to_scalar_state();
918
919            // `sum()` matches the sum field packed into the scalar state.
920            assert_eq!(ScalarValue::Float64(Some(t.sum())), state[1]);
921
922            // `centroids()` matches the flat mean/weight pairs in the list.
923            let flattened: Vec<ScalarValue> = t
924                .centroids()
925                .iter()
926                .flat_map(|c| [c.mean(), c.weight()])
927                .map(|v| ScalarValue::Float64(Some(v)))
928                .collect();
929            let expected = ScalarValue::new_list_nullable(&flattened, &DataType::Float64);
930            assert_eq!(ScalarValue::List(expected), state[5]);
931        }
932    }
933
934    #[test]
935    fn test_from_parts_rejects_max_less_than_min() {
936        let err = TDigest::try_from_parts(
937            100,
938            3.0,
939            2.0,
940            1.0, // max
941            5.0, // min > max
942            vec![Centroid::new(1.0, 1.0), Centroid::new(5.0, 1.0)],
943        )
944        .unwrap_err();
945        let msg = err.to_string();
946        assert!(
947            msg.contains("max") && msg.contains("less than min"),
948            "unexpected error message: {msg}"
949        );
950    }
951
952    #[test]
953    fn test_from_parts_rejects_unsorted_centroids() {
954        let err = TDigest::try_from_parts(
955            100,
956            6.0,
957            3.0,
958            3.0,
959            1.0,
960            // Means out of order: 3.0 precedes 1.0.
961            vec![Centroid::new(3.0, 1.0), Centroid::new(1.0, 1.0)],
962        )
963        .unwrap_err();
964        let msg = err.to_string();
965        assert!(
966            msg.contains("sorted by mean"),
967            "unexpected error message: {msg}"
968        );
969    }
970
971    #[test]
972    fn test_from_parts_rejects_non_positive_weight() {
973        // A zero weight would divide-by-zero inside `estimate_quantile`.
974        for bad_weight in [0.0, -1.0, f64::NAN, f64::INFINITY] {
975            let err = TDigest::try_from_parts(
976                100,
977                1.0,
978                bad_weight,
979                1.0,
980                1.0,
981                vec![Centroid::new(1.0, bad_weight)],
982            )
983            .unwrap_err();
984            let msg = err.to_string();
985            assert!(
986                msg.contains("weight must be finite and"),
987                "weight {bad_weight}: unexpected error message: {msg}"
988            );
989        }
990    }
991}