1use 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
41macro_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#[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#[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 #[inline]
153 pub fn sum(&self) -> f64 {
154 self.sum
155 }
156
157 #[inline]
164 pub fn centroids(&self) -> &[Centroid] {
165 &self.centroids
166 }
167
168 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 let (min, max) = if lo > hi { (hi, lo) } else { (lo, hi) };
205
206 v.clamp(min, max)
207 }
208
209 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(¢roids[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 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 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 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 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 pub fn to_scalar_state(&self) -> Vec<ScalarValue> {
564 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(¢roids, &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 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 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 ¢roids {
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 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 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 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 let result = t.estimate_quantile(0.99);
845 assert!((result - 15.699999988079073).abs() < 1e-10);
847 }
848
849 fn sample_digests() -> Vec<TDigest> {
853 vec![
854 TDigest::new(100),
856 TDigest::new(100).merge_unsorted_f64(vec![42.0]),
858 TDigest::new(100).merge_unsorted_f64((1..=10_000).map(f64::from).collect()),
860 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 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 assert_eq!(rebuilt.to_scalar_state(), t.to_scalar_state());
890
891 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 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 assert_eq!(ScalarValue::Float64(Some(t.sum())), state[1]);
921
922 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, 5.0, 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 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 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}