diff --git a/benchmarks/tdigest/compress.rs b/benchmarks/tdigest/compress.rs index 3fd4cb63..2f94a73e 100644 --- a/benchmarks/tdigest/compress.rs +++ b/benchmarks/tdigest/compress.rs @@ -38,7 +38,7 @@ fn initial_buffer(bencher: Bencher) { fn unmerged_tail(bencher: Bencher) { let values = values(3_280); let mut digest = build_mut_digest(&values[..1_640]); - black_box(digest.rank(0.5)); + black_box(digest.rank(0.5).unwrap().unwrap()); for &value in &values[1_640..] { digest.update(value); } diff --git a/benchmarks/tdigest/merge.rs b/benchmarks/tdigest/merge.rs index 13559c66..78b54241 100644 --- a/benchmarks/tdigest/merge.rs +++ b/benchmarks/tdigest/merge.rs @@ -34,8 +34,8 @@ fn merge(bencher: Bencher) { let values = values(200_000); let mut left = build_mut_digest(&values[..100_000]); let mut right = build_mut_digest(&values[100_000..]); - black_box(left.rank(0.5)); - black_box(right.rank(0.5)); + black_box(left.rank(0.5).unwrap().unwrap()); + black_box(right.rank(0.5).unwrap().unwrap()); bencher .counter(ItemsCount::new(values.len())) @@ -65,7 +65,7 @@ fn small_partials(bencher: Bencher) { let partials = partial_digests(64, SMALL_ROWS_PER_PARTIAL) .into_iter() .map(|mut digest| { - black_box(digest.rank(0.0)); + black_box(digest.rank(0.0).unwrap().unwrap()); digest }) .collect::>(); @@ -86,7 +86,7 @@ fn partials(bencher: Bencher) { let partials = partial_digests_with(DEFAULT_DIGEST_K, 64, ROWS_PER_PARTIAL) .into_iter() .map(|mut digest| { - black_box(digest.rank(0.0)); + black_box(digest.rank(0.0).unwrap().unwrap()); digest }) .collect::>(); diff --git a/datasketches/src/kll/mod.rs b/datasketches/src/kll/mod.rs index 469bc7f1..bd3a88d0 100644 --- a/datasketches/src/kll/mod.rs +++ b/datasketches/src/kll/mod.rs @@ -36,7 +36,10 @@ //! let mut sketch = KllSketch::::new(200).unwrap(); //! sketch.update(1); //! sketch.update(2); -//! let q = sketch.quantile(0.5, SearchCriteria::Inclusive).unwrap(); +//! let q = sketch +//! .quantile(0.5, SearchCriteria::Inclusive) +//! .unwrap() +//! .unwrap(); //! assert!((1..=2).contains(&q)); //! ``` diff --git a/datasketches/src/kll/sketch.rs b/datasketches/src/kll/sketch.rs index 38b9245f..415403c1 100644 --- a/datasketches/src/kll/sketch.rs +++ b/datasketches/src/kll/sketch.rs @@ -201,12 +201,10 @@ impl KllSketch { /// Returns the normalized rank of the given item. /// - /// # Errors - /// - /// Returns an error if the sketch is empty. - pub fn rank(&self, item: &T, criteria: SearchCriteria) -> Result { + /// Returns `None` if the sketch is empty. + pub fn rank(&self, item: &T, criteria: SearchCriteria) -> Option { if self.is_empty() { - return Err(Error::invalid_argument("cannot query an empty sketch")); + return None; } let inclusive = criteria == SearchCriteria::Inclusive; let mut weight = 0u64; @@ -221,23 +219,25 @@ impl KllSketch { .count() as u64; weight += count << level; } - Ok(weight as f64 / self.n as f64) + Some(weight as f64 / self.n as f64) } /// Returns the quantile for the given normalized rank. /// + /// Returns `Ok(None)` if the sketch is empty. + /// /// # Errors /// - /// Returns an error if the sketch is empty or `rank` is outside `[0.0, 1.0]`. - pub fn quantile(&self, rank: f64, criteria: SearchCriteria) -> Result { - if self.is_empty() { - return Err(Error::invalid_argument("cannot query an empty sketch")); - } + /// Returns an error if `rank` is outside `[0.0, 1.0]`. + pub fn quantile(&self, rank: f64, criteria: SearchCriteria) -> 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}" ))); } + if self.is_empty() { + return Ok(None); + } self.sorted_view().quantile(rank, criteria) } @@ -245,36 +245,46 @@ impl KllSketch { /// /// The sorted view is built once for the whole batch. /// + /// Returns `Ok(None)` if the sketch is empty. + /// /// # Errors /// - /// Returns an error if the sketch is empty or any rank is outside `[0.0, 1.0]`. - pub fn quantiles(&self, ranks: &[f64], criteria: SearchCriteria) -> Result, Error> { + /// Returns an error if any rank is outside `[0.0, 1.0]`. + pub fn quantiles( + &self, + ranks: &[f64], + criteria: SearchCriteria, + ) -> Result>, Error> { self.sorted_view().quantiles(ranks, criteria) } /// Returns the approximate CDF for the given split points. /// + /// Returns `Ok(None)` if the sketch is empty. + /// /// # Errors /// - /// Returns an error if the sketch is empty or the split points are not unique and strictly - /// increasing. - pub fn cdf(&self, split_points: &[T], criteria: SearchCriteria) -> Result, Error> { - if self.is_empty() { - return Err(Error::invalid_argument("cannot query an empty sketch")); - } + /// Returns an error if the split points are not unique and strictly increasing. + pub fn cdf( + &self, + split_points: &[T], + criteria: SearchCriteria, + ) -> Result>, Error> { self.sorted_view().cdf(split_points, criteria) } /// Returns the approximate PMF for the given split points. /// + /// Returns `Ok(None)` if the sketch is empty. + /// /// # Errors /// - /// Returns an error if the sketch is empty or the split points are not unique and strictly - /// increasing. - pub fn pmf(&self, split_points: &[T], criteria: SearchCriteria) -> Result, Error> { - if self.is_empty() { - return Err(Error::invalid_argument("cannot query an empty sketch")); - } + /// Returns an error if the split points are not unique and strictly increasing. + pub fn pmf( + &self, + split_points: &[T], + criteria: SearchCriteria, + ) -> Result>, Error> { self.sorted_view().pmf(split_points, criteria) } diff --git a/datasketches/src/kll/sorted_view.rs b/datasketches/src/kll/sorted_view.rs index b58a69a9..0fe79275 100644 --- a/datasketches/src/kll/sorted_view.rs +++ b/datasketches/src/kll/sorted_view.rs @@ -66,12 +66,10 @@ impl SortedView { /// Returns the approximate normalized rank of `item`. /// - /// # Errors - /// - /// Returns an error if the view is empty. - pub fn rank(&self, item: &T, criteria: SearchCriteria) -> Result { + /// Returns `None` if the view is empty. + pub fn rank(&self, item: &T, criteria: SearchCriteria) -> Option { if self.is_empty() { - return Err(Error::invalid_argument("cannot query an empty view")); + return None; } let index = if criteria == SearchCriteria::Inclusive { upper_bound(&self.entries, item) @@ -80,25 +78,27 @@ impl SortedView { }; if index == 0 { - return Ok(0.0); + return Some(0.0); } - Ok(self.entries[index - 1].cumulative_weight as f64 / self.total_weight as f64) + Some(self.entries[index - 1].cumulative_weight as f64 / self.total_weight as f64) } /// Returns the approximate quantile for `rank`. /// + /// Returns `Ok(None)` if the view is empty. + /// /// # Errors /// - /// Returns an error if the view is empty or `rank` is outside `[0.0, 1.0]`. - pub fn quantile(&self, rank: f64, criteria: SearchCriteria) -> Result { - if self.is_empty() { - return Err(Error::invalid_argument("cannot query an empty view")); - } + /// Returns an error if `rank` is outside `[0.0, 1.0]`. + pub fn quantile(&self, rank: f64, criteria: SearchCriteria) -> 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}" ))); } + if self.is_empty() { + return Ok(None); + } let weight = if criteria == SearchCriteria::Inclusive { (rank * self.total_weight as f64).ceil() as u64 @@ -111,53 +111,86 @@ impl SortedView { upper_bound_by_weight(&self.entries, weight) }; - Ok(self.entries[index.min(self.entries.len() - 1)].item.clone()) + Ok(Some( + self.entries[index.min(self.entries.len() - 1)].item.clone(), + )) } /// Returns approximate quantiles for all `ranks`. /// + /// Returns `Ok(None)` if the view is empty. + /// /// # Errors /// - /// Returns an error if the view is empty or any rank is outside `[0.0, 1.0]`. - pub fn quantiles(&self, ranks: &[f64], criteria: SearchCriteria) -> Result, Error> { + /// Returns an error if any rank is outside `[0.0, 1.0]`. + pub fn quantiles( + &self, + ranks: &[f64], + criteria: SearchCriteria, + ) -> Result>, Error> { + for &rank in ranks { + if !(0.0..=1.0).contains(&rank) { + return Err(Error::invalid_argument(format!( + "rank must be in [0.0, 1.0], got {rank}" + ))); + } + } if self.is_empty() { - return Err(Error::invalid_argument("cannot query an empty view")); + return Ok(None); } - ranks + let quantiles = ranks .iter() - .map(|&rank| self.quantile(rank, criteria)) - .collect() + .map(|&rank| { + self.quantile(rank, criteria) + .map(|quantile| quantile.expect("checked non-empty view")) + }) + .collect::>()?; + Ok(Some(quantiles)) } /// Returns the approximate cumulative distribution over `split_points`. /// + /// Returns `Ok(None)` if the view is empty. + /// /// # Errors /// - /// Returns an error if the view is empty or the split points are invalid. - pub fn cdf(&self, split_points: &[T], criteria: SearchCriteria) -> Result, Error> { + /// Returns an error if the split points are invalid. + pub fn cdf( + &self, + split_points: &[T], + criteria: SearchCriteria, + ) -> Result>, Error> { + check_split_points(split_points)?; if self.is_empty() { - return Err(Error::invalid_argument("cannot query an empty view")); + return Ok(None); } - check_split_points(split_points)?; let mut ranks = Vec::with_capacity(split_points.len() + 1); for item in split_points { - ranks.push(self.rank(item, criteria)?); + ranks.push(self.rank(item, criteria).expect("checked non-empty view")); } ranks.push(1.0); - Ok(ranks) + Ok(Some(ranks)) } /// Returns the approximate probability mass over `split_points`. /// + /// Returns `Ok(None)` if the view is empty. + /// /// # Errors /// - /// Returns an error if the view is empty or the split points are invalid. - pub fn pmf(&self, split_points: &[T], criteria: SearchCriteria) -> Result, Error> { - let mut buckets = self.cdf(split_points, criteria)?; + /// Returns an error if the split points are invalid. + pub fn pmf( + &self, + split_points: &[T], + criteria: SearchCriteria, + ) -> Result>, Error> { + let Some(mut buckets) = self.cdf(split_points, criteria)? else { + return Ok(None); + }; for index in (1..buckets.len()).rev() { buckets[index] -= buckets[index - 1]; } - Ok(buckets) + Ok(Some(buckets)) } } diff --git a/datasketches/src/req/mod.rs b/datasketches/src/req/mod.rs index ce5a65fe..6b6cae08 100644 --- a/datasketches/src/req/mod.rs +++ b/datasketches/src/req/mod.rs @@ -44,7 +44,9 @@ //! sketch.update(ReqFloat::::new(value)?); //! } //! -//! let median = sketch.quantile(0.5, SearchCriteria::Inclusive)?; +//! let median = sketch +//! .quantile(0.5, SearchCriteria::Inclusive)? +//! .expect("the sketch is non-empty"); //! assert_eq!(median.into_inner(), 2.0); //! # Ok::<(), datasketches::error::Error>(()) //! ``` diff --git a/datasketches/src/req/sketch.rs b/datasketches/src/req/sketch.rs index 89a3a70c..a2b6d7b5 100644 --- a/datasketches/src/req/sketch.rs +++ b/datasketches/src/req/sketch.rs @@ -176,12 +176,10 @@ where /// without building a sorted view. The result is identical to /// [`SortedView::rank`] on [`Self::sorted_view`]. /// - /// # Errors - /// - /// Returns an error if the sketch is empty. - pub fn rank(&self, item: &T, criteria: SearchCriteria) -> Result { + /// Returns `None` if the sketch is empty. + pub fn rank(&self, item: &T, criteria: SearchCriteria) -> Option { if self.is_empty() { - return Err(Error::invalid_argument("sketch is empty")); + return None; } let inclusive = matches!(criteria, SearchCriteria::Inclusive); let weight: u64 = self @@ -189,7 +187,7 @@ where .iter() .map(|c| c.count_below(item, inclusive) as u64 * c.weight()) .sum(); - Ok(weight as f64 / self.n as f64) + Some(weight as f64 / self.n as f64) } /// Returns the approximate quantile at the given normalized rank. @@ -197,18 +195,20 @@ where /// Builds a transient [`SortedView`] internally. For repeated quantile /// queries, take one snapshot with [`Self::sorted_view`] and query it. /// + /// Returns `Ok(None)` if the sketch is empty. + /// /// # Errors /// - /// Returns an error if the sketch is empty or `rank` is outside `[0.0, 1.0]`. - pub fn quantile(&self, rank: f64, criteria: SearchCriteria) -> Result { - if self.is_empty() { - return Err(Error::invalid_argument("sketch is empty")); - } + /// Returns an error if `rank` is outside `[0.0, 1.0]`. + pub fn quantile(&self, rank: f64, criteria: SearchCriteria) -> Result, Error> { if !(0.0..=1.0).contains(&rank) { return Err(Error::invalid_argument(format!( "rank {rank} must be in [0, 1]" ))); } + if self.is_empty() { + return Ok(None); + } self.sorted_view().quantile(rank, criteria) } @@ -216,14 +216,16 @@ where /// /// The sorted view is built once and shared across all ranks. /// + /// Returns `Ok(None)` if the sketch is empty. + /// /// # Errors /// - /// Returns an error if the sketch is empty or any rank is outside `[0.0, 1.0]`. - pub fn quantiles(&self, ranks: &[f64], criteria: SearchCriteria) -> Result, Error> { - if self.is_empty() { - return Err(Error::invalid_argument("sketch is empty")); - } - // Reject invalid ranks before paying for the view build. + /// Returns an error if any rank is outside `[0.0, 1.0]`. + pub fn quantiles( + &self, + ranks: &[f64], + criteria: SearchCriteria, + ) -> Result>, Error> { for &r in ranks { if !(0.0..=1.0).contains(&r) { return Err(Error::invalid_argument(format!( @@ -231,37 +233,53 @@ where ))); } } + if self.is_empty() { + return Ok(None); + } let view = self.sorted_view(); - ranks.iter().map(|&r| view.quantile(r, criteria)).collect() + let quantiles = ranks + .iter() + .map(|&rank| { + view.quantile(rank, criteria) + .map(|quantile| quantile.expect("checked non-empty sketch")) + }) + .collect::>()?; + Ok(Some(quantiles)) } /// Returns the Probability Mass Function over the given split points. /// + /// Returns `Ok(None)` if the sketch is empty. + /// /// # Errors /// - /// Returns an error if the sketch is empty or the split points are not strictly increasing. - pub fn pmf(&self, split_points: &[T], criteria: SearchCriteria) -> Result, Error> { - if self.is_empty() { - return Err(Error::invalid_argument("sketch is empty")); - } + /// Returns an error if the split points are not strictly increasing. + pub fn pmf( + &self, + split_points: &[T], + criteria: SearchCriteria, + ) -> Result>, Error> { self.sorted_view().pmf(split_points, criteria) } /// Returns the Cumulative Distribution Function over the given split points. /// + /// Returns `Ok(None)` if the sketch is empty. + /// /// # Errors /// - /// Returns an error if the sketch is empty or the split points are not strictly increasing. - pub fn cdf(&self, split_points: &[T], criteria: SearchCriteria) -> Result, Error> { - if self.is_empty() { - return Err(Error::invalid_argument("sketch is empty")); - } + /// Returns an error if the split points are not strictly increasing. + pub fn cdf( + &self, + split_points: &[T], + criteria: SearchCriteria, + ) -> Result>, Error> { self.sorted_view().cdf(split_points, criteria) } /// Returns an owned, sorted snapshot of the sketch's current state. /// - /// An empty sketch yields an empty view; queries on it return an error. The + /// An empty sketch yields an empty view; queries on it return `None`. The /// view is independent of the sketch — it can be queried (and sent to other /// threads) while the sketch keeps receiving updates, and it keeps answering /// from the state it was taken at. diff --git a/datasketches/src/req/sorted_view.rs b/datasketches/src/req/sorted_view.rs index 8affbbb7..84121b6c 100644 --- a/datasketches/src/req/sorted_view.rs +++ b/datasketches/src/req/sorted_view.rs @@ -102,12 +102,10 @@ where /// Returns the approximate normalized rank of `item` in `[0.0, 1.0]`. /// - /// # Errors - /// - /// Returns an error if the view is empty. - pub fn rank(&self, item: &T, criteria: SearchCriteria) -> Result { + /// Returns `None` if the view is empty. + pub fn rank(&self, item: &T, criteria: SearchCriteria) -> Option { if self.is_empty() { - return Err(Error::invalid_argument("sketch is empty")); + return None; } match criteria { SearchCriteria::Inclusive => { @@ -115,18 +113,18 @@ where // partition_point finds first index where predicate is false let pos = self.items.partition_point(|x| x <= item); if pos == 0 { - Ok(0.0) + Some(0.0) } else { - Ok(self.cumulative_weights[pos - 1] as f64 / self.total_weight as f64) + Some(self.cumulative_weights[pos - 1] as f64 / self.total_weight as f64) } } SearchCriteria::Exclusive => { // Find the last position where items[i] < item let pos = self.items.partition_point(|x| x < item); if pos == 0 { - Ok(0.0) + Some(0.0) } else { - Ok(self.cumulative_weights[pos - 1] as f64 / self.total_weight as f64) + Some(self.cumulative_weights[pos - 1] as f64 / self.total_weight as f64) } } } @@ -134,29 +132,30 @@ where /// Returns the approximate quantile at the given normalized rank. /// + /// Returns `Ok(None)` if the view is empty. + /// /// # Errors /// - /// Returns an error if the view is empty or `rank` is outside `[0.0, 1.0]`. - pub fn quantile(&self, rank: f64, criteria: SearchCriteria) -> Result { - if self.is_empty() { - return Err(Error::invalid_argument("sketch is empty")); - } - + /// Returns an error if `rank` is outside `[0.0, 1.0]`. + pub fn quantile(&self, rank: f64, criteria: SearchCriteria) -> Result, Error> { if !(0.0..=1.0).contains(&rank) { return Err(Error::invalid_argument(format!( "rank {rank} must be in [0, 1]" ))); } + if self.is_empty() { + return Ok(None); + } // Handle edge cases if rank == 0.0 { match criteria { - SearchCriteria::Inclusive => return Ok(self.items[0].clone()), - SearchCriteria::Exclusive => return Ok(self.items[0].clone()), + SearchCriteria::Inclusive => return Ok(Some(self.items[0].clone())), + SearchCriteria::Exclusive => return Ok(Some(self.items[0].clone())), } } if rank == 1.0 { - return Ok(self.items[self.items.len() - 1].clone()); + return Ok(Some(self.items[self.items.len() - 1].clone())); } // Convert rank to target cumulative weight @@ -181,31 +180,38 @@ where }; if index >= self.items.len() { - return Ok(self.items[self.items.len() - 1].clone()); + return Ok(Some(self.items[self.items.len() - 1].clone())); } - Ok(self.items[index].clone()) + Ok(Some(self.items[index].clone())) } /// Returns the probability mass function (PMF) over the given split points. /// /// The result contains one more value than `split_points`. /// + /// Returns `Ok(None)` if the view is empty. + /// /// # Errors /// - /// Returns an error if the view is empty or the split points are not strictly increasing. - pub fn pmf(&self, split_points: &[T], criteria: SearchCriteria) -> Result, Error> { + /// Returns an error if the split points are not strictly increasing. + pub fn pmf( + &self, + split_points: &[T], + criteria: SearchCriteria, + ) -> Result>, Error> { + self.validate_split_points(split_points)?; if self.is_empty() { - return Err(Error::invalid_argument("sketch is empty")); + return Ok(None); } - self.validate_split_points(split_points)?; - let mut result = Vec::with_capacity(split_points.len() + 1); let mut prev_rank = 0.0; for split_point in split_points { - let rank = self.rank(split_point, criteria)?; + let rank = self + .rank(split_point, criteria) + .expect("checked non-empty view"); result.push(rank - prev_rank); prev_rank = rank; } @@ -213,33 +219,40 @@ where // Add the final interval result.push(1.0 - prev_rank); - Ok(result) + Ok(Some(result)) } /// Returns the cumulative distribution function (CDF) over the given split points. /// /// The result contains one more value than `split_points` and ends at `1.0`. /// + /// Returns `Ok(None)` if the view is empty. + /// /// # Errors /// - /// Returns an error if the view is empty or the split points are not strictly increasing. - pub fn cdf(&self, split_points: &[T], criteria: SearchCriteria) -> Result, Error> { + /// Returns an error if the split points are not strictly increasing. + pub fn cdf( + &self, + split_points: &[T], + criteria: SearchCriteria, + ) -> Result>, Error> { + self.validate_split_points(split_points)?; if self.is_empty() { - return Err(Error::invalid_argument("sketch is empty")); + return Ok(None); } - self.validate_split_points(split_points)?; - let mut result = Vec::with_capacity(split_points.len() + 1); let mut cumulative = 0.0; - let pmf = self.pmf(split_points, criteria)?; + let pmf = self + .pmf(split_points, criteria)? + .expect("checked non-empty view"); for mass in pmf { cumulative += mass; result.push(cumulative); } - Ok(result) + Ok(Some(result)) } fn validate_split_points(&self, split_points: &[T]) -> Result<(), Error> { @@ -256,8 +269,6 @@ where mod tests { use googletest::assert_that; use googletest::prelude::all; - use googletest::prelude::anything; - use googletest::prelude::err; use googletest::prelude::ge; use googletest::prelude::le; use googletest::prelude::near; @@ -282,16 +293,34 @@ mod tests { let view = create_test_view(); // Test exact matches - assert_that!(view.rank(&1, SearchCriteria::Inclusive)?, near(0.2, 1e-10)); - assert_that!(view.rank(&1, SearchCriteria::Exclusive)?, near(0.0, 1e-10)); + assert_that!( + view.rank(&1, SearchCriteria::Inclusive).unwrap(), + near(0.2, 1e-10) + ); + assert_that!( + view.rank(&1, SearchCriteria::Exclusive).unwrap(), + near(0.0, 1e-10) + ); // Test values between items - assert_that!(view.rank(&2, SearchCriteria::Inclusive)?, near(0.2, 1e-10)); - assert_that!(view.rank(&6, SearchCriteria::Inclusive)?, near(0.6, 1e-10)); + assert_that!( + view.rank(&2, SearchCriteria::Inclusive).unwrap(), + near(0.2, 1e-10) + ); + assert_that!( + view.rank(&6, SearchCriteria::Inclusive).unwrap(), + near(0.6, 1e-10) + ); // Test edge cases - assert_that!(view.rank(&0, SearchCriteria::Inclusive)?, near(0.0, 1e-10)); - assert_that!(view.rank(&10, SearchCriteria::Inclusive)?, near(1.0, 1e-10)); + assert_that!( + view.rank(&0, SearchCriteria::Inclusive).unwrap(), + near(0.0, 1e-10) + ); + assert_that!( + view.rank(&10, SearchCriteria::Inclusive).unwrap(), + near(1.0, 1e-10) + ); Ok(()) } @@ -300,16 +329,16 @@ mod tests { let view = create_test_view(); // Test edge cases - assert_eq!(view.quantile(0.0, SearchCriteria::Inclusive)?, 1); - assert_eq!(view.quantile(1.0, SearchCriteria::Inclusive)?, 9); + assert_eq!(view.quantile(0.0, SearchCriteria::Inclusive)?.unwrap(), 1); + assert_eq!(view.quantile(1.0, SearchCriteria::Inclusive)?.unwrap(), 9); // Test middle values - let median = view.quantile(0.5, SearchCriteria::Inclusive)?; + let median = view.quantile(0.5, SearchCriteria::Inclusive)?.unwrap(); assert_that!(median, all!(ge(3), le(7))); // Should be around the middle (values are 1,3,5,7,9) // Test various ranks - let q25 = view.quantile(0.25, SearchCriteria::Inclusive)?; - let q75 = view.quantile(0.75, SearchCriteria::Inclusive)?; + let q25 = view.quantile(0.25, SearchCriteria::Inclusive)?.unwrap(); + let q75 = view.quantile(0.75, SearchCriteria::Inclusive)?.unwrap(); assert_that!(q25, le(median)); assert_that!(median, le(q75)); Ok(()) @@ -320,7 +349,7 @@ mod tests { let view = create_test_view(); let split_points = vec![3, 7]; - let pmf = view.pmf(&split_points, SearchCriteria::Inclusive)?; + let pmf = view.pmf(&split_points, SearchCriteria::Inclusive)?.unwrap(); assert_eq!(pmf.len(), 3); // 2 split points create 3 intervals // Sum should be approximately 1.0 @@ -334,7 +363,7 @@ mod tests { let view = create_test_view(); let split_points = vec![3, 7]; - let cdf = view.cdf(&split_points, SearchCriteria::Inclusive)?; + let cdf = view.cdf(&split_points, SearchCriteria::Inclusive)?.unwrap(); assert_eq!(cdf.len(), 3); // CDF should be monotonically increasing @@ -354,11 +383,10 @@ mod tests { assert_eq!(view.len(), 0); assert_eq!(view.total_weight(), 0); - // Operations on empty view should return errors - assert_that!(view.rank(&5, SearchCriteria::Inclusive), err(anything())); - assert_that!( + assert_eq!(view.rank(&5, SearchCriteria::Inclusive), None); + assert!(matches!( view.quantile(0.5, SearchCriteria::Inclusive), - err(anything()) - ); + Ok(None) + )); } } diff --git a/datasketches/src/tdigest/mod.rs b/datasketches/src/tdigest/mod.rs index 7d07de86..0ce7d3d6 100644 --- a/datasketches/src/tdigest/mod.rs +++ b/datasketches/src/tdigest/mod.rs @@ -56,9 +56,9 @@ //! let mut sketch = TDigestMut::new(100).unwrap(); //! sketch.update(1.0); //! sketch.update(2.0); -//! let median = sketch.quantile(0.5).unwrap(); +//! let median = sketch.quantile(0.5).unwrap().unwrap(); //! let frozen = sketch.freeze(); -//! assert!(frozen.rank(2.0).is_some()); +//! assert!(frozen.rank(2.0).unwrap().is_some()); //! ``` mod serialization; diff --git a/datasketches/src/tdigest/sketch.rs b/datasketches/src/tdigest/sketch.rs index d496a9c5..c8f80554 100644 --- a/datasketches/src/tdigest/sketch.rs +++ b/datasketches/src/tdigest/sketch.rs @@ -384,10 +384,12 @@ impl TDigestMut { /// Returns the cumulative distribution approximation described by [`TDigest::cdf`]. /// - /// # Panics + /// Returns `Ok(None)` if this t-digest is empty. /// - /// Panics if `split_points` is not unique, not monotonically increasing, or contains `NaN` - /// values. + /// # Errors + /// + /// Returns `InvalidArgument` if `split_points` is not unique, not monotonically increasing, or + /// contains `NaN` values. /// /// # Examples /// @@ -398,25 +400,27 @@ impl TDigestMut { /// for value in [1.0, 2.0, 3.0] { /// sketch.update(value); /// } - /// let cdf = sketch.cdf(&[1.5]).unwrap(); + /// let cdf = sketch.cdf(&[1.5]).unwrap().unwrap(); /// assert_eq!(cdf.len(), 2); /// ``` - pub fn cdf(&mut self, split_points: &[f64]) -> Option> { - check_split_points(split_points); + pub fn cdf(&mut self, split_points: &[f64]) -> Result>, Error> { + check_split_points(split_points)?; if self.is_empty() { - return None; + return Ok(None); } - self.view().cdf(split_points) + Ok(self.view().cdf(split_points)) } /// Returns the probability mass approximation described by [`TDigest::pmf`]. /// - /// # Panics + /// Returns `Ok(None)` if this t-digest is empty. + /// + /// # Errors /// - /// Panics if `split_points` is not unique, not monotonically increasing, or contains `NaN` - /// values. + /// Returns `InvalidArgument` if `split_points` is not unique, not monotonically increasing, or + /// contains `NaN` values. /// /// # Examples /// @@ -427,24 +431,26 @@ impl TDigestMut { /// for value in [1.0, 2.0, 3.0] { /// sketch.update(value); /// } - /// let pmf = sketch.pmf(&[1.5]).unwrap(); + /// let pmf = sketch.pmf(&[1.5]).unwrap().unwrap(); /// assert_eq!(pmf.len(), 2); /// ``` - pub fn pmf(&mut self, split_points: &[f64]) -> Option> { - check_split_points(split_points); + pub fn pmf(&mut self, split_points: &[f64]) -> Result>, Error> { + check_split_points(split_points)?; if self.is_empty() { - return None; + return Ok(None); } - self.view().pmf(split_points) + Ok(self.view().pmf(split_points)) } /// Returns the normalized rank described by [`TDigest::rank`]. /// - /// # Panics + /// Returns `Ok(None)` if this t-digest is empty. + /// + /// # Errors /// - /// Panics if `value` is `NaN`. + /// Returns `InvalidArgument` if `value` is `NaN`. /// /// # Examples /// @@ -455,34 +461,38 @@ impl TDigestMut { /// for value in [1.0, 2.0, 3.0] { /// sketch.update(value); /// } - /// let rank = sketch.rank(2.0).unwrap(); + /// let rank = sketch.rank(2.0).unwrap().unwrap(); /// assert!((0.0..=1.0).contains(&rank)); /// ``` - pub fn rank(&mut self, value: f64) -> Option { - assert!(!value.is_nan(), "value must not be NaN"); + pub fn rank(&mut self, value: f64) -> Result, Error> { + if value.is_nan() { + return Err(Error::invalid_argument("value must not be NaN")); + } if self.is_empty() { - return None; + return Ok(None); } if value < self.min { - return Some(0.0); + return Ok(Some(0.0)); } if value > self.max { - return Some(1.0); + return Ok(Some(1.0)); } // one centroid and value == min == max if self.buffer.len() == 1 { - return Some(0.5); + return Ok(Some(0.5)); } - self.view().rank(value) + Ok(self.view().rank(value)) } /// Returns the quantile described by [`TDigest::quantile`]. /// - /// # Panics + /// Returns `Ok(None)` if this t-digest is empty. + /// + /// # Errors /// - /// Panics if `rank` is outside `[0.0, 1.0]`. + /// Returns `InvalidArgument` if `rank` is outside `[0.0, 1.0]`. /// /// # Examples /// @@ -493,17 +503,21 @@ impl TDigestMut { /// for value in [1.0, 2.0, 3.0] { /// sketch.update(value); /// } - /// let median = sketch.quantile(0.5).unwrap(); + /// let median = sketch.quantile(0.5).unwrap().unwrap(); /// assert!((1.0..=3.0).contains(&median)); /// ``` - pub fn quantile(&mut self, rank: f64) -> Option { - assert!((0.0..=1.0).contains(&rank), "rank must be in [0.0, 1.0]"); + pub fn quantile(&mut self, 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}" + ))); + } if self.is_empty() { - return None; + return Ok(None); } - self.view().quantile(rank) + Ok(self.view().quantile(rank)) } /// Serializes this mutable t-digest to bytes. @@ -1154,12 +1168,12 @@ impl TDigest { /// This can be viewed as array of ranks of the given split points plus one more value that /// is always 1. An empty `split_points` slice returns the single value `[1.0]`. /// - /// Returns `None` if this t-digest is empty. + /// Returns `Ok(None)` if this t-digest is empty. /// - /// # Panics + /// # Errors /// - /// Panics if `split_points` is not unique, not monotonically increasing, or contains `NaN` - /// values. + /// Returns `InvalidArgument` if `split_points` is not unique, not monotonically increasing, or + /// contains `NaN` values. /// /// # Examples /// @@ -1171,11 +1185,12 @@ impl TDigest { /// sketch.update(value); /// } /// let digest = sketch.freeze(); - /// let cdf = digest.cdf(&[1.5]).unwrap(); + /// let cdf = digest.cdf(&[1.5]).unwrap().unwrap(); /// assert_eq!(cdf.len(), 2); /// ``` - pub fn cdf(&self, split_points: &[f64]) -> Option> { - self.view().cdf(split_points) + pub fn cdf(&self, split_points: &[f64]) -> Result>, Error> { + check_split_points(split_points)?; + Ok(self.view().cdf(split_points)) } /// Returns an approximation to the Probability Mass Function (PMF) of the input stream @@ -1192,12 +1207,12 @@ impl TDigest { /// stream values (the mass) that fall into one of those intervals. /// An empty `split_points` slice returns the single value `[1.0]`. /// - /// Returns `None` if this t-digest is empty. + /// Returns `Ok(None)` if this t-digest is empty. /// - /// # Panics + /// # Errors /// - /// Panics if `split_points` is not unique, not monotonically increasing, or contains `NaN` - /// values. + /// Returns `InvalidArgument` if `split_points` is not unique, not monotonically increasing, or + /// contains `NaN` values. /// /// # Examples /// @@ -1209,20 +1224,21 @@ impl TDigest { /// sketch.update(value); /// } /// let digest = sketch.freeze(); - /// let pmf = digest.pmf(&[1.5]).unwrap(); + /// let pmf = digest.pmf(&[1.5]).unwrap().unwrap(); /// assert_eq!(pmf.len(), 2); /// ``` - pub fn pmf(&self, split_points: &[f64]) -> Option> { - self.view().pmf(split_points) + pub fn pmf(&self, split_points: &[f64]) -> Result>, Error> { + check_split_points(split_points)?; + Ok(self.view().pmf(split_points)) } /// Computes the approximate normalized rank in `[0.0, 1.0]` of the given value. /// - /// Returns `None` if this t-digest is empty. + /// Returns `Ok(None)` if this t-digest is empty. /// - /// # Panics + /// # Errors /// - /// Panics if the value is `NaN`. + /// Returns `InvalidArgument` if `value` is `NaN`. /// /// # Examples /// @@ -1234,21 +1250,23 @@ impl TDigest { /// sketch.update(value); /// } /// let digest = sketch.freeze(); - /// let rank = digest.rank(2.0).unwrap(); + /// let rank = digest.rank(2.0).unwrap().unwrap(); /// assert!((0.0..=1.0).contains(&rank)); /// ``` - pub fn rank(&self, value: f64) -> Option { - assert!(!value.is_nan(), "value must not be NaN"); - self.view().rank(value) + pub fn rank(&self, value: f64) -> Result, Error> { + if value.is_nan() { + return Err(Error::invalid_argument("value must not be NaN")); + } + Ok(self.view().rank(value)) } /// Computes the approximate quantile for the given normalized rank. /// - /// Returns `None` if this t-digest is empty. + /// Returns `Ok(None)` if this t-digest is empty. /// - /// # Panics + /// # Errors /// - /// Panics if `rank` is outside `[0.0, 1.0]`. + /// Returns `InvalidArgument` if `rank` is outside `[0.0, 1.0]`. /// /// # Examples /// @@ -1260,12 +1278,16 @@ impl TDigest { /// sketch.update(value); /// } /// let digest = sketch.freeze(); - /// let q = digest.quantile(0.5).unwrap(); + /// let q = digest.quantile(0.5).unwrap().unwrap(); /// assert!((1.0..=3.0).contains(&q)); /// ``` - pub fn quantile(&self, rank: f64) -> Option { - assert!((0.0..=1.0).contains(&rank), "rank must be in [0.0, 1.0]"); - self.view().quantile(rank) + pub fn quantile(&self, 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(self.view().quantile(rank)) } /// Converts this immutable t-digest into a mutable one. @@ -1316,7 +1338,7 @@ impl TDigestView<'_> { } fn cdf(&self, split_points: &[f64]) -> Option> { - check_split_points(split_points); + debug_assert!(check_split_points(split_points).is_ok()); if self.centroids.is_empty() { return None; @@ -1514,16 +1536,18 @@ impl TDigestView<'_> { } } -/// Checks the sequential validity of the given array of double values. -/// They must be unique, monotonically increasing and not NaN. -#[track_caller] -fn check_split_points(split_points: &[f64]) { +fn check_split_points(split_points: &[f64]) -> Result<(), Error> { if split_points.iter().any(|split_point| split_point.is_nan()) { - panic!("split_points must not contain NaN values: {split_points:?}"); + return Err(Error::invalid_argument(format!( + "split_points must not contain NaN values: {split_points:?}" + ))); } if !split_points.windows(2).all(|pair| pair[0] < pair[1]) { - panic!("split_points must be unique and monotonically increasing: {split_points:?}"); + return Err(Error::invalid_argument(format!( + "split_points must be unique and monotonically increasing: {split_points:?}" + ))); } + Ok(()) } fn centroid_cmp(a: &Centroid, b: &Centroid) -> Ordering { diff --git a/tests-integration/tests/kll_test/generic.rs b/tests-integration/tests/kll_test/generic.rs index 14d06677..544f6798 100644 --- a/tests-integration/tests/kll_test/generic.rs +++ b/tests-integration/tests/kll_test/generic.rs @@ -68,7 +68,11 @@ fn custom_item_order_controls_queries_and_survives_roundtrip() { assert_eq!(sketch.min_item().map(|item| item.0.as_str()), Some("1")); assert_eq!(sketch.max_item().map(|item| item.0.as_str()), Some("10")); assert_eq!( - sketch.quantile(0.5, SearchCriteria::Inclusive).unwrap().0, + sketch + .quantile(0.5, SearchCriteria::Inclusive) + .unwrap() + .unwrap() + .0, "2" ); @@ -78,7 +82,11 @@ fn custom_item_order_controls_queries_and_survives_roundtrip() { assert_eq!(decoded.min_item().map(|item| item.0.as_str()), Some("1")); assert_eq!(decoded.max_item().map(|item| item.0.as_str()), Some("10")); assert_eq!( - decoded.quantile(0.5, SearchCriteria::Inclusive).unwrap().0, + decoded + .quantile(0.5, SearchCriteria::Inclusive) + .unwrap() + .unwrap() + .0, "2" ); } diff --git a/tests-integration/tests/kll_test/merge.rs b/tests-integration/tests/kll_test/merge.rs index 417fa1b1..513f0dbc 100644 --- a/tests-integration/tests/kll_test/merge.rs +++ b/tests-integration/tests/kll_test/merge.rs @@ -35,6 +35,7 @@ fn merge_preserves_weight_extrema_and_query_invariants() { assert_eq!(left.sorted_view().total_weight(), left.n()); let quantiles = left .quantiles(&[0.0, 0.25, 0.5, 0.75, 1.0], SearchCriteria::Inclusive) + .unwrap() .unwrap(); assert!(quantiles.windows(2).all(|pair| pair[0] <= pair[1])); } diff --git a/tests-integration/tests/kll_test/query.rs b/tests-integration/tests/kll_test/query.rs index 5474df95..e72dc13f 100644 --- a/tests-integration/tests/kll_test/query.rs +++ b/tests-integration/tests/kll_test/query.rs @@ -23,22 +23,76 @@ const DEFAULT_K: u16 = 200; const NUMERIC_NOISE_TOLERANCE: f64 = 1e-6; #[test] -fn empty_and_invalid_queries_return_errors() { - let mut sketch = KllSketch::::new(DEFAULT_K).unwrap(); - assert!(sketch.rank(&0, SearchCriteria::Inclusive).is_err()); - assert!(sketch.quantile(0.5, SearchCriteria::Inclusive).is_err()); - assert!(sketch.pmf(&[0], SearchCriteria::Inclusive).is_err()); - assert!(sketch.cdf(&[0], SearchCriteria::Inclusive).is_err()); +fn empty_queries_return_none_and_invalid_queries_return_errors() { + let sketch = KllSketch::::new(DEFAULT_K).unwrap(); + assert_eq!(sketch.rank(&0, SearchCriteria::Inclusive), None); + assert!(matches!( + sketch.quantile(0.5, SearchCriteria::Inclusive), + Ok(None) + )); + assert!(matches!( + sketch.quantiles(&[0.25, 0.75], SearchCriteria::Inclusive), + Ok(None) + )); + assert!(matches!( + sketch.pmf(&[0], SearchCriteria::Inclusive), + Ok(None) + )); + assert!(matches!( + sketch.cdf(&[0], SearchCriteria::Inclusive), + Ok(None) + )); - sketch.update(0); for rank in [-1.0, f64::NAN, 1.1] { let error = sketch .quantile(rank, SearchCriteria::Inclusive) .unwrap_err(); assert_eq!(error.kind(), ErrorKind::InvalidArgument); } - let error = sketch.cdf(&[1, 0], SearchCriteria::Inclusive).unwrap_err(); + let error = sketch + .quantiles(&[0.5, 1.1], SearchCriteria::Inclusive) + .unwrap_err(); assert_eq!(error.kind(), ErrorKind::InvalidArgument); + for error in [ + sketch.cdf(&[1, 0], SearchCriteria::Inclusive).unwrap_err(), + sketch.pmf(&[1, 0], SearchCriteria::Inclusive).unwrap_err(), + ] { + assert_eq!(error.kind(), ErrorKind::InvalidArgument); + } + + let view = sketch.sorted_view(); + assert_eq!(view.rank(&0, SearchCriteria::Inclusive), None); + assert!(matches!( + view.quantile(0.5, SearchCriteria::Inclusive), + Ok(None) + )); + assert!(matches!( + view.quantiles(&[0.25, 0.75], SearchCriteria::Inclusive), + Ok(None) + )); + assert!(matches!( + view.pmf(&[0], SearchCriteria::Inclusive), + Ok(None) + )); + assert!(matches!( + view.cdf(&[0], SearchCriteria::Inclusive), + Ok(None) + )); + + for rank in [-1.0, f64::NAN, 1.1] { + let error = view.quantile(rank, SearchCriteria::Inclusive).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::InvalidArgument); + } + let error = view + .quantiles(&[0.5, 1.1], SearchCriteria::Inclusive) + .unwrap_err(); + assert_eq!(error.kind(), ErrorKind::InvalidArgument); + for error in [ + view.cdf(&[1, 0], SearchCriteria::Inclusive).unwrap_err(), + view.pmf(&[1, 0], SearchCriteria::Inclusive).unwrap_err(), + ] { + assert_eq!(error.kind(), ErrorKind::InvalidArgument); + } } #[test] @@ -52,8 +106,20 @@ fn inclusive_and_exclusive_semantics_cover_duplicates() { assert_eq!(sketch.rank(&1, SearchCriteria::Inclusive).unwrap(), 0.5); assert_eq!(sketch.rank(&2, SearchCriteria::Exclusive).unwrap(), 0.5); assert_eq!(sketch.rank(&2, SearchCriteria::Inclusive).unwrap(), 1.0); - assert_eq!(sketch.quantile(0.5, SearchCriteria::Inclusive).unwrap(), 1); - assert_eq!(sketch.quantile(0.5, SearchCriteria::Exclusive).unwrap(), 2); + assert_eq!( + sketch + .quantile(0.5, SearchCriteria::Inclusive) + .unwrap() + .unwrap(), + 1 + ); + assert_eq!( + sketch + .quantile(0.5, SearchCriteria::Exclusive) + .unwrap() + .unwrap(), + 2 + ); } #[test] @@ -63,10 +129,25 @@ fn exact_mode_queries_match_the_stream() { sketch.update(item); } - assert_eq!(sketch.quantile(0.0, SearchCriteria::Inclusive).unwrap(), 1); - assert_eq!(sketch.quantile(0.5, SearchCriteria::Inclusive).unwrap(), 50); assert_eq!( - sketch.quantile(1.0, SearchCriteria::Inclusive).unwrap(), + sketch + .quantile(0.0, SearchCriteria::Inclusive) + .unwrap() + .unwrap(), + 1 + ); + assert_eq!( + sketch + .quantile(0.5, SearchCriteria::Inclusive) + .unwrap() + .unwrap(), + 50 + ); + assert_eq!( + sketch + .quantile(1.0, SearchCriteria::Inclusive) + .unwrap() + .unwrap(), 100 ); for item in 1..=100 { @@ -105,8 +186,8 @@ fn rank_cdf_and_pmf_are_consistent() { let split_points: Vec<_> = (100..10_000).step_by(100).collect(); for criteria in [SearchCriteria::Inclusive, SearchCriteria::Exclusive] { - let cdf = sketch.cdf(&split_points, criteria).unwrap(); - let pmf = sketch.pmf(&split_points, criteria).unwrap(); + let cdf = sketch.cdf(&split_points, criteria).unwrap().unwrap(); + let pmf = sketch.pmf(&split_points, criteria).unwrap().unwrap(); let mut subtotal = 0.0; for (index, split_point) in split_points.iter().enumerate() { subtotal += pmf[index]; @@ -125,17 +206,24 @@ fn sorted_view_supports_repeated_and_batch_queries() { } let view = sketch.sorted_view(); let ranks = [0.0, 0.25, 0.5, 0.75, 1.0]; - let quantiles = sketch.quantiles(&ranks, SearchCriteria::Inclusive).unwrap(); + let quantiles = sketch + .quantiles(&ranks, SearchCriteria::Inclusive) + .unwrap() + .unwrap(); assert_eq!(view.len(), sketch.num_retained()); assert_eq!(view.total_weight(), sketch.n()); assert_eq!( - view.quantiles(&ranks, SearchCriteria::Inclusive).unwrap(), - quantiles + view.quantiles(&ranks, SearchCriteria::Inclusive) + .unwrap() + .unwrap(), + quantiles, ); for (&rank, quantile) in ranks.iter().zip(&quantiles) { assert_eq!( - view.quantile(rank, SearchCriteria::Inclusive).unwrap(), + view.quantile(rank, SearchCriteria::Inclusive) + .unwrap() + .unwrap(), *quantile ); assert_eq!( @@ -146,5 +234,10 @@ fn sorted_view_supports_repeated_and_batch_queries() { sketch.update(2_000); assert_eq!(view.total_weight(), 1_000); - assert_eq!(view.quantile(1.0, SearchCriteria::Inclusive).unwrap(), 999); + assert_eq!( + view.quantile(1.0, SearchCriteria::Inclusive) + .unwrap() + .unwrap(), + 999 + ); } diff --git a/tests-integration/tests/req_test/accuracy.rs b/tests-integration/tests/req_test/accuracy.rs index 76c318cc..0fc361c7 100644 --- a/tests-integration/tests/req_test/accuracy.rs +++ b/tests-integration/tests/req_test/accuracy.rs @@ -38,8 +38,12 @@ fn rank_space_error_is_bounded() -> Result<(), Error> { assert_eq!(sketch.n(), n as u64); for rank in [0.1, 0.25, 0.5, 0.75, 0.9, 0.95, 0.99] { - let quantile = sketch.quantile(rank, SearchCriteria::Inclusive)?; - let estimated_rank = sketch.rank(&quantile, SearchCriteria::Inclusive)?; + let quantile = sketch + .quantile(rank, SearchCriteria::Inclusive)? + .expect("the sketch is non-empty"); + let estimated_rank = sketch + .rank(&quantile, SearchCriteria::Inclusive) + .expect("the sketch is non-empty"); let abs_rank_error = (estimated_rank - rank).abs(); let max_abs_rank_error = if rank >= 0.9 { 0.01 } else { 0.02 }; diff --git a/tests-integration/tests/req_test/bounds.rs b/tests-integration/tests/req_test/bounds.rs index d6ee3a77..a0c61992 100644 --- a/tests-integration/tests/req_test/bounds.rs +++ b/tests-integration/tests/req_test/bounds.rs @@ -77,7 +77,9 @@ fn theoretical_error_bounds_cover_uniform_quantiles() -> Result<(), Error> { 0.95, 0.97, 0.98, 0.99, 0.995, 0.999, ] { let true_quantile = req_f64(rank * (n - 1) as f64); - let estimated_rank = sketch.rank(&true_quantile, SearchCriteria::Inclusive)?; + let estimated_rank = sketch + .rank(&true_quantile, SearchCriteria::Inclusive) + .expect("the sketch is non-empty"); let lower = sketch.rank_lower_bound(rank, NumStdDev::Three); let upper = sketch.rank_upper_bound(rank, NumStdDev::Three); assert_that!(estimated_rank, all!(ge(lower), le(upper)), "rank: {rank}"); diff --git a/tests-integration/tests/req_test/core.rs b/tests-integration/tests/req_test/core.rs index 737b4892..1abb286b 100644 --- a/tests-integration/tests/req_test/core.rs +++ b/tests-integration/tests/req_test/core.rs @@ -39,7 +39,7 @@ use super::req_f32; use super::req_f64; #[test] -fn empty_sketch_has_default_state_and_rejects_queries() { +fn empty_sketch_has_default_state_and_absent_queries() { let sketch: ReqSketch = ReqSketch::default(); assert_eq!(sketch.k(), 12); @@ -50,22 +50,23 @@ fn empty_sketch_has_default_state_and_rejects_queries() { assert_that!(sketch.min_item(), none()); assert_that!(sketch.max_item(), none()); - assert_that!( - sketch.rank(&req_f64(0.0), SearchCriteria::Inclusive), - err(anything()) - ); - assert_that!( + assert_eq!(sketch.rank(&req_f64(0.0), SearchCriteria::Inclusive), None); + assert!(matches!( sketch.quantile(0.5, SearchCriteria::Inclusive), - err(anything()) - ); - assert_that!( + Ok(None) + )); + assert!(matches!( + sketch.quantiles(&[0.25, 0.75], SearchCriteria::Inclusive), + Ok(None) + )); + assert!(matches!( sketch.pmf(&[req_f64(0.0)], SearchCriteria::Inclusive), - err(anything()) - ); - assert_that!( + Ok(None) + )); + assert!(matches!( sketch.cdf(&[req_f64(0.0)], SearchCriteria::Inclusive), - err(anything()) - ); + Ok(None) + )); } #[test] @@ -109,7 +110,8 @@ fn single_value_hra_answers_exactly() { assert_eq!( sketch .quantile(rank, SearchCriteria::Exclusive) - .expect("quantile should succeed"), + .expect("quantile should succeed") + .expect("the sketch is non-empty"), req_f32(1.0) ); } @@ -218,14 +220,18 @@ fn small_edge_cases_answer_reasonably() -> Result<(), Error> { let mut single: ReqSketch = ReqSketch::default(); single.update(req_f64(42.0)); assert_eq!( - single.quantile(0.5, SearchCriteria::Inclusive)?, + single + .quantile(0.5, SearchCriteria::Inclusive)? + .expect("the sketch is non-empty"), req_f64(42.0) ); let mut two_values = ReqSketch::default(); two_values.update(req_f64(1.0)); two_values.update(req_f64(100.0)); - let median = two_values.quantile(0.5, SearchCriteria::Inclusive)?; + let median = two_values + .quantile(0.5, SearchCriteria::Inclusive)? + .expect("the sketch is non-empty"); assert_that!(*median, all!(ge(1.0), le(100.0))); let mut duplicates = ReqSketch::default(); @@ -233,7 +239,9 @@ fn small_edge_cases_answer_reasonably() -> Result<(), Error> { duplicates.update(req_f64(42.0)); } assert_eq!( - duplicates.quantile(0.5, SearchCriteria::Inclusive)?, + duplicates + .quantile(0.5, SearchCriteria::Inclusive)? + .expect("the sketch is non-empty"), req_f64(42.0) ); diff --git a/tests-integration/tests/req_test/generic.rs b/tests-integration/tests/req_test/generic.rs index a405e815..ab4335b5 100644 --- a/tests-integration/tests/req_test/generic.rs +++ b/tests-integration/tests/req_test/generic.rs @@ -31,7 +31,10 @@ fn custom_items_do_not_need_serialization() { assert_eq!(sketch.min_item(), Some(&Reading(10))); assert_eq!(sketch.max_item(), Some(&Reading(30))); assert_eq!( - sketch.quantile(0.5, SearchCriteria::Inclusive).unwrap(), + sketch + .quantile(0.5, SearchCriteria::Inclusive) + .unwrap() + .unwrap(), Reading(20) ); } diff --git a/tests-integration/tests/req_test/merge.rs b/tests-integration/tests/req_test/merge.rs index d4c80e29..d24d6e7c 100644 --- a/tests-integration/tests/req_test/merge.rs +++ b/tests-integration/tests/req_test/merge.rs @@ -43,13 +43,16 @@ fn merge_into_empty_preserves_source_distribution() { let q25 = target .quantile(0.25, SearchCriteria::Inclusive) - .expect("quantile should succeed"); + .expect("quantile should succeed") + .expect("the sketch is non-empty"); let q50 = target .quantile(0.5, SearchCriteria::Inclusive) - .expect("quantile should succeed"); + .expect("quantile should succeed") + .expect("the sketch is non-empty"); let q75 = target .quantile(0.75, SearchCriteria::Inclusive) - .expect("quantile should succeed"); + .expect("quantile should succeed") + .expect("the sketch is non-empty"); let r50 = target .rank(&req_f64(500.0), SearchCriteria::Inclusive) .expect("rank should succeed"); @@ -78,13 +81,16 @@ fn merge_two_ranges_preserves_distribution() { let q25 = left .quantile(0.25, SearchCriteria::Inclusive) - .expect("quantile should succeed"); + .expect("quantile should succeed") + .expect("the sketch is non-empty"); let q50 = left .quantile(0.5, SearchCriteria::Inclusive) - .expect("quantile should succeed"); + .expect("quantile should succeed") + .expect("the sketch is non-empty"); let q75 = left .quantile(0.75, SearchCriteria::Inclusive) - .expect("quantile should succeed"); + .expect("quantile should succeed") + .expect("the sketch is non-empty"); let r50 = left .rank(&req_f64(1000.0), SearchCriteria::Inclusive) .expect("rank should succeed"); @@ -122,6 +128,7 @@ fn many_small_merges_preserve_count_bounds_and_median() { let median = sketch .quantile(0.5, SearchCriteria::Inclusive) - .expect("quantile should succeed"); + .expect("quantile should succeed") + .expect("the sketch is non-empty"); assert_that!(*median, near(4999.5, 500.0)); } diff --git a/tests-integration/tests/req_test/property.rs b/tests-integration/tests/req_test/property.rs index cf7a8bd5..24d91df7 100644 --- a/tests-integration/tests/req_test/property.rs +++ b/tests-integration/tests/req_test/property.rs @@ -48,7 +48,8 @@ fn prop_quantile_rank_consistency() { for rank in [0.1, 0.25, 0.5, 0.75, 0.9] { let quantile = sketch .quantile(rank, SearchCriteria::Inclusive) - .expect("quantile should succeed"); + .expect("quantile should succeed") + .expect("the sketch is non-empty"); let recovered = sketch .rank(&quantile, SearchCriteria::Inclusive) .expect("rank should succeed"); @@ -96,7 +97,8 @@ fn prop_sketch_bounds() { for rank in [0.0, 0.25, 0.5, 0.75, 1.0] { let quantile = sketch .quantile(rank, SearchCriteria::Inclusive) - .expect("quantile should succeed"); + .expect("quantile should succeed") + .expect("the sketch is non-empty"); assert!( quantile >= true_min && quantile <= true_max, "quantile {} out of bounds [{}, {}]", diff --git a/tests-integration/tests/req_test/query.rs b/tests-integration/tests/req_test/query.rs index 4fb2e62b..41203a93 100644 --- a/tests-integration/tests/req_test/query.rs +++ b/tests-integration/tests/req_test/query.rs @@ -63,7 +63,8 @@ fn exact_mode_rank_quantile_pmf_and_cdf_match_reference() { assert_eq!( *sketch .quantile(rank, SearchCriteria::Exclusive) - .expect("quantile should succeed"), + .expect("quantile should succeed") + .expect("the sketch is non-empty"), expected ); } @@ -72,7 +73,8 @@ fn exact_mode_rank_quantile_pmf_and_cdf_match_reference() { assert_eq!( *sketch .quantile(rank, SearchCriteria::Inclusive) - .expect("quantile should succeed"), + .expect("quantile should succeed") + .expect("the sketch is non-empty"), expected ); } @@ -80,7 +82,8 @@ fn exact_mode_rank_quantile_pmf_and_cdf_match_reference() { let splits = [2.0, 6.0, 9.0].map(req_f64); let cdf = sketch .cdf(&splits, SearchCriteria::Exclusive) - .expect("cdf should succeed"); + .expect("cdf should succeed") + .expect("the sketch is non-empty"); assert_that!(cdf[0], near(0.1, 1e-6)); assert_that!(cdf[1], near(0.5, 1e-6)); assert_that!(cdf[2], near(0.8, 1e-6)); @@ -88,7 +91,8 @@ fn exact_mode_rank_quantile_pmf_and_cdf_match_reference() { let pmf = sketch .pmf(&splits, SearchCriteria::Exclusive) - .expect("pmf should succeed"); + .expect("pmf should succeed") + .expect("the sketch is non-empty"); assert_that!(pmf[0], near(0.1, 1e-6)); assert_that!(pmf[1], near(0.4, 1e-6)); assert_that!(pmf[2], near(0.3, 1e-6)); @@ -105,10 +109,12 @@ fn pmf_and_cdf_are_consistent() { let split_points = [100.0, 300.0, 500.0, 700.0, 900.0].map(req_f64); let pmf = sketch .pmf(&split_points, SearchCriteria::Inclusive) - .expect("pmf should succeed"); + .expect("pmf should succeed") + .expect("the sketch is non-empty"); let cdf = sketch .cdf(&split_points, SearchCriteria::Inclusive) - .expect("cdf should succeed"); + .expect("cdf should succeed") + .expect("the sketch is non-empty"); assert_that!(pmf.iter().sum::(), near(1.0, 1e-10)); @@ -151,7 +157,9 @@ fn quantiles_are_monotonic() -> Result<(), Error> { let mut previous = 0.0; for rank in ranks { - let quantile = sketch.quantile(rank, SearchCriteria::Inclusive)?; + let quantile = sketch + .quantile(rank, SearchCriteria::Inclusive)? + .expect("the sketch is non-empty"); assert_that!(*quantile, ge(previous)); previous = *quantile; } @@ -167,8 +175,12 @@ fn rank_quantile_round_trip_is_consistent() -> Result<(), Error> { } for target_rank in [0.1, 0.25, 0.5, 0.75, 0.9] { - let quantile = sketch.quantile(target_rank, SearchCriteria::Inclusive)?; - let recovered_rank = sketch.rank(&quantile, SearchCriteria::Inclusive)?; + let quantile = sketch + .quantile(target_rank, SearchCriteria::Inclusive)? + .expect("the sketch is non-empty"); + let recovered_rank = sketch + .rank(&quantile, SearchCriteria::Inclusive) + .expect("the sketch is non-empty"); let error = (recovered_rank - target_rank).abs() / target_rank; assert_that!(error, lt(0.2)); } @@ -184,8 +196,12 @@ fn search_criteria_rank_consistency() -> Result<(), Error> { } for value in [100.0, 250.0, 500.0, 750.0].map(req_f64) { - let inclusive_rank = sketch.rank(&value, SearchCriteria::Inclusive)?; - let exclusive_rank = sketch.rank(&value, SearchCriteria::Exclusive)?; + let inclusive_rank = sketch + .rank(&value, SearchCriteria::Inclusive) + .expect("the sketch is non-empty"); + let exclusive_rank = sketch + .rank(&value, SearchCriteria::Exclusive) + .expect("the sketch is non-empty"); assert_that!(exclusive_rank, le(inclusive_rank)); assert_that!(inclusive_rank, all!(ge(0.0), le(1.0))); @@ -204,8 +220,18 @@ fn signed_zeros_share_rank_and_cannot_be_distinct_splits() -> Result<(), Error> sketch.update(positive_zero); for value in [negative_zero, positive_zero] { - assert_eq!(sketch.rank(&value, SearchCriteria::Exclusive)?, 0.0); - assert_eq!(sketch.rank(&value, SearchCriteria::Inclusive)?, 1.0); + assert_eq!( + sketch + .rank(&value, SearchCriteria::Exclusive) + .expect("the sketch is non-empty"), + 0.0 + ); + assert_eq!( + sketch + .rank(&value, SearchCriteria::Inclusive) + .expect("the sketch is non-empty"), + 1.0 + ); } assert!( diff --git a/tests-integration/tests/req_test/sorted_view_api.rs b/tests-integration/tests/req_test/sorted_view_api.rs index 59c4e6dc..20cc0541 100644 --- a/tests-integration/tests/req_test/sorted_view_api.rs +++ b/tests-integration/tests/req_test/sorted_view_api.rs @@ -25,9 +25,7 @@ use datasketches::req::ReqSketch; use datasketches::req::SortedView; use googletest::assert_that; use googletest::prelude::all; -use googletest::prelude::anything; use googletest::prelude::contains_substring; -use googletest::prelude::err; use googletest::prelude::ge; use googletest::prelude::lt; use googletest::prelude::near; @@ -47,19 +45,23 @@ fn populated_sketch(n: i64) -> ReqSketch { fn query_through_shared_ref(sketch: &ReqSketch) { sketch .quantile(0.5, SearchCriteria::Inclusive) - .expect("quantile"); + .expect("quantile") + .expect("the sketch is non-empty"); sketch .quantiles(&[0.25, 0.5, 0.75], SearchCriteria::Inclusive) - .expect("quantiles"); + .expect("quantiles") + .expect("the sketch is non-empty"); sketch .rank(&req_f64(50.0), SearchCriteria::Inclusive) .expect("rank"); sketch .pmf(&[req_f64(10.0), req_f64(50.0)], SearchCriteria::Inclusive) - .expect("pmf"); + .expect("pmf") + .expect("the sketch is non-empty"); sketch .cdf(&[req_f64(10.0), req_f64(50.0)], SearchCriteria::Inclusive) - .expect("cdf"); + .expect("cdf") + .expect("the sketch is non-empty"); assert!(!sketch.sorted_view().is_empty()); } @@ -95,24 +97,32 @@ fn sorted_view_on_empty_sketch_is_an_empty_view() { assert!(view.is_empty()); assert_eq!(view.len(), 0); assert_eq!(view.total_weight(), 0); - // Queries on the empty view still report an error. - assert_that!( + assert_eq!(view.rank(&req_f64(0.0), SearchCriteria::Inclusive), None); + assert!(matches!( view.quantile(0.5, SearchCriteria::Inclusive), - err(anything()) - ); + Ok(None) + )); + assert!(matches!( + view.pmf(&[req_f64(0.0)], SearchCriteria::Inclusive), + Ok(None) + )); + assert!(matches!( + view.cdf(&[req_f64(0.0)], SearchCriteria::Inclusive), + Ok(None) + )); } #[test] -fn empty_sketch_pmf_cdf_report_error() { +fn empty_sketch_pmf_cdf_report_absence() { let sketch: ReqSketch = ReqSketch::default(); - assert_that!( + assert!(matches!( sketch.pmf(&[req_f64(1.0)], SearchCriteria::Inclusive), - err(anything()) - ); - assert_that!( + Ok(None) + )); + assert!(matches!( sketch.cdf(&[req_f64(1.0)], SearchCriteria::Inclusive), - err(anything()) - ); + Ok(None) + )); } #[test] @@ -126,11 +136,33 @@ fn view_rank_is_primary_query_name() { } #[test] -fn error_precedence_empty_before_invalid_rank() { - // On an empty sketch the emptiness is reported before the out-of-range rank. +fn invalid_queries_are_reported_before_empty_state() { let empty: ReqSketch = ReqSketch::default(); - let empty_err = empty.quantile(2.0, SearchCriteria::Inclusive).unwrap_err(); - assert_that!(empty_err.message(), contains_substring("empty")); + for error in [ + empty.quantile(2.0, SearchCriteria::Inclusive).unwrap_err(), + empty + .quantiles(&[0.5, 2.0], SearchCriteria::Inclusive) + .unwrap_err(), + empty + .cdf(&[req_f64(1.0), req_f64(0.0)], SearchCriteria::Inclusive) + .unwrap_err(), + empty + .pmf(&[req_f64(1.0), req_f64(0.0)], SearchCriteria::Inclusive) + .unwrap_err(), + ] { + assert_eq!(error.kind(), ErrorKind::InvalidArgument); + } + + let view = empty.sorted_view(); + for error in [ + view.quantile(2.0, SearchCriteria::Inclusive).unwrap_err(), + view.cdf(&[req_f64(1.0), req_f64(0.0)], SearchCriteria::Inclusive) + .unwrap_err(), + view.pmf(&[req_f64(1.0), req_f64(0.0)], SearchCriteria::Inclusive) + .unwrap_err(), + ] { + assert_eq!(error.kind(), ErrorKind::InvalidArgument); + } // On a populated sketch the out-of-range rank is reported. let sketch = populated_sketch(10); @@ -157,6 +189,7 @@ fn concurrent_readers_share_the_sketch() { sketch .quantile(rank, SearchCriteria::Inclusive) .expect("quantile from shared sketch") + .expect("the sketch is non-empty") }) }) .collect(); diff --git a/tests-integration/tests/serde_tests/req.rs b/tests-integration/tests/serde_tests/req.rs index 397f894d..0e310600 100644 --- a/tests-integration/tests/serde_tests/req.rs +++ b/tests-integration/tests/serde_tests/req.rs @@ -510,7 +510,10 @@ fn validate_cross_language_fixture(path: PathBuf, expected_n: u64) { sketch.max_item().copied().map(ReqFloat::into_inner), Some(expected_n as f32) ); - let _ = sketch.quantile(0.5, SearchCriteria::Inclusive).unwrap(); + sketch + .quantile(0.5, SearchCriteria::Inclusive) + .unwrap() + .expect("non-empty snapshot should have a median"); } let serialized = sketch.serialize(); diff --git a/tests-integration/tests/serde_tests/tdigest.rs b/tests-integration/tests/serde_tests/tdigest.rs index 1b871ef8..391f450e 100644 --- a/tests-integration/tests/serde_tests/tdigest.rs +++ b/tests-integration/tests/serde_tests/tdigest.rs @@ -64,13 +64,17 @@ fn test_sketch_file(path: PathBuf, n: u64, with_buffer: bool, is_f32: bool) { assert_eq!(td.total_weight(), n, "filepath: {path}"); assert_eq!(td.min_value(), Some(1.0), "filepath: {path}"); assert_eq!(td.max_value(), Some(n as f64), "filepath: {path}"); - assert_eq!(td.rank(0.0), Some(0.0), "filepath: {path}"); - assert_eq!(td.rank((n + 1) as f64), Some(1.0), "filepath: {path}"); + assert_eq!(td.rank(0.0).unwrap(), Some(0.0), "filepath: {path}"); + assert_eq!( + td.rank((n + 1) as f64).unwrap(), + Some(1.0), + "filepath: {path}" + ); if n == 1 { - assert_eq!(td.rank(n as f64), Some(0.5), "filepath: {path}"); + assert_eq!(td.rank(n as f64).unwrap(), Some(0.5), "filepath: {path}"); } else { assert_that!( - td.rank(n as f64 / 2.).unwrap(), + td.rank(n as f64 / 2.).unwrap().unwrap(), near(0.5, 0.05), "filepath: {path}", ); @@ -126,23 +130,31 @@ fn test_deserialize_from_reference_implementation() { assert_eq!(td.total_weight(), n, "filepath: {path}"); assert_eq!(td.min_value(), Some(0.0), "filepath: {path}"); assert_eq!(td.max_value(), Some((n - 1) as f64), "filepath: {path}"); - assert_that!(td.rank(0.0).unwrap(), near(0.0, 0.0001), "filepath: {path}"); assert_that!( - td.rank(n as f64 / 4.).unwrap(), + td.rank(0.0).unwrap().unwrap(), + near(0.0, 0.0001), + "filepath: {path}" + ); + assert_that!( + td.rank(n as f64 / 4.).unwrap().unwrap(), near(0.25, 0.0001), "filepath: {path}" ); assert_that!( - td.rank(n as f64 / 2.).unwrap(), + td.rank(n as f64 / 2.).unwrap().unwrap(), near(0.5, 0.0001), "filepath: {path}" ); assert_that!( - td.rank((n * 3) as f64 / 4.).unwrap(), + td.rank((n * 3) as f64 / 4.).unwrap().unwrap(), near(0.75, 0.0001), "filepath: {path}" ); - assert_that!(td.rank(n as f64).unwrap(), eq(1.0), "filepath: {path}"); + assert_that!( + td.rank(n as f64).unwrap().unwrap(), + eq(1.0), + "filepath: {path}" + ); } } @@ -223,8 +235,14 @@ fn test_many_values() { assert_eq!(td.is_empty(), deserialized_td.is_empty()); assert_eq!(td.min_value(), deserialized_td.min_value()); assert_eq!(td.max_value(), deserialized_td.max_value()); - assert_eq!(td.rank(500.0), deserialized_td.rank(500.0)); - assert_eq!(td.quantile(0.5), deserialized_td.quantile(0.5)); + assert_eq!( + td.rank(500.0).unwrap(), + deserialized_td.rank(500.0).unwrap() + ); + assert_eq!( + td.quantile(0.5).unwrap(), + deserialized_td.quantile(0.5).unwrap() + ); } #[test] @@ -239,7 +257,10 @@ fn test_frozen_roundtrip() { assert_eq!(actual.total_weight(), expected.total_weight()); assert_eq!(actual.min_value(), expected.min_value()); assert_eq!(actual.max_value(), expected.max_value()); - assert_eq!(actual.quantile(0.5), expected.quantile(0.5)); + assert_eq!( + actual.quantile(0.5).unwrap(), + expected.quantile(0.5).unwrap() + ); } #[test] @@ -251,7 +272,7 @@ fn test_serialized_bytes_stable_for_full_and_merged_digests() { let mut left = patterned_digest(10, 201, 2); let mut right = patterned_digest(10, 199, 3); - right.rank(0.0); + right.rank(0.0).unwrap(); left.merge(&right); let bytes = left.serialize(); assert_eq!(bytes.len(), 272); @@ -430,6 +451,6 @@ fn test_large_weights_produce_finite_extreme_quantile() { bytes[56..64].copy_from_slice(&(1_u64 << 52).to_le_bytes()); let mut tdigest = TDigestMut::deserialize(&bytes).unwrap(); - let quantile = tdigest.quantile(0.25).unwrap(); + let quantile = tdigest.quantile(0.25).unwrap().unwrap(); assert_that!(quantile, all!(is_finite(), ge(lower), le(f64::MAX))); } diff --git a/tests-integration/tests/tdigest_test/property.rs b/tests-integration/tests/tdigest_test/property.rs index 28ac0b4a..b2d4615f 100644 --- a/tests-integration/tests/tdigest_test/property.rs +++ b/tests-integration/tests/tdigest_test/property.rs @@ -39,7 +39,7 @@ fn assert_quantiles_are_monotonic(tdigest: &mut TDigestMut) { for step in 0..=RANK_STEPS { let rank = step as f64 / RANK_STEPS as f64; - let quantile = tdigest.quantile(rank).unwrap(); + let quantile = tdigest.quantile(rank).unwrap().unwrap(); assert!( (previous..=max).contains(&quantile), "quantile {quantile} at rank {rank} is outside [{previous}, {max}]" diff --git a/tests-integration/tests/tdigest_test/sketch.rs b/tests-integration/tests/tdigest_test/sketch.rs index 302cc4c7..13d4c56c 100644 --- a/tests-integration/tests/tdigest_test/sketch.rs +++ b/tests-integration/tests/tdigest_test/sketch.rs @@ -17,6 +17,7 @@ use std::mem::size_of; +use datasketches::error::ErrorKind; use datasketches::tdigest::TDigestMut; use googletest::assert_that; use googletest::prelude::eq; @@ -31,12 +32,12 @@ fn test_empty() { assert_eq!(tdigest.total_weight(), 0); assert_eq!(tdigest.min_value(), None); assert_eq!(tdigest.max_value(), None); - assert_eq!(tdigest.rank(0.0), None); - assert_eq!(tdigest.quantile(0.5), None); + assert_eq!(tdigest.rank(0.0).unwrap(), None); + assert_eq!(tdigest.quantile(0.5).unwrap(), None); let split_points = [0.0]; - assert_eq!(tdigest.pmf(&split_points), None); - assert_eq!(tdigest.cdf(&split_points), None); + assert_eq!(tdigest.pmf(&split_points).unwrap(), None); + assert_eq!(tdigest.cdf(&split_points).unwrap(), None); let tdigest = TDigestMut::new(10).unwrap().freeze(); assert!(tdigest.is_empty()); @@ -44,12 +45,39 @@ fn test_empty() { assert_eq!(tdigest.total_weight(), 0); assert_eq!(tdigest.min_value(), None); assert_eq!(tdigest.max_value(), None); - assert_eq!(tdigest.rank(0.0), None); - assert_eq!(tdigest.quantile(0.5), None); + assert_eq!(tdigest.rank(0.0).unwrap(), None); + assert_eq!(tdigest.quantile(0.5).unwrap(), None); let split_points = [0.0]; - assert_eq!(tdigest.pmf(&split_points), None); - assert_eq!(tdigest.cdf(&split_points), None); + assert_eq!(tdigest.pmf(&split_points).unwrap(), None); + assert_eq!(tdigest.cdf(&split_points).unwrap(), None); +} + +#[test] +fn test_invalid_query_arguments_return_errors() { + let mut tdigest = TDigestMut::new(10).unwrap(); + for error in [ + tdigest.rank(f64::NAN).unwrap_err(), + tdigest.quantile(f64::NAN).unwrap_err(), + tdigest.quantile(-0.1).unwrap_err(), + tdigest.quantile(1.1).unwrap_err(), + tdigest.cdf(&[1.0, 0.0]).unwrap_err(), + tdigest.pmf(&[0.0, f64::NAN]).unwrap_err(), + ] { + assert_eq!(error.kind(), ErrorKind::InvalidArgument); + } + + let tdigest = tdigest.freeze(); + for error in [ + tdigest.rank(f64::NAN).unwrap_err(), + tdigest.quantile(f64::NAN).unwrap_err(), + tdigest.quantile(-0.1).unwrap_err(), + tdigest.quantile(1.1).unwrap_err(), + tdigest.cdf(&[1.0, 0.0]).unwrap_err(), + tdigest.pmf(&[0.0, f64::NAN]).unwrap_err(), + ] { + assert_eq!(error.kind(), ErrorKind::InvalidArgument); + } } #[test] @@ -60,12 +88,12 @@ fn test_one_value() { assert_eq!(tdigest.total_weight(), 1); assert_eq!(tdigest.min_value(), Some(1.0)); assert_eq!(tdigest.max_value(), Some(1.0)); - assert_eq!(tdigest.rank(0.99), Some(0.0)); - assert_eq!(tdigest.rank(1.0), Some(0.5)); - assert_eq!(tdigest.rank(1.01), Some(1.0)); - assert_eq!(tdigest.quantile(0.0), Some(1.0)); - assert_eq!(tdigest.quantile(0.5), Some(1.0)); - assert_eq!(tdigest.quantile(1.0), Some(1.0)); + assert_eq!(tdigest.rank(0.99).unwrap(), Some(0.0)); + assert_eq!(tdigest.rank(1.0).unwrap(), Some(0.5)); + assert_eq!(tdigest.rank(1.01).unwrap(), Some(1.0)); + assert_eq!(tdigest.quantile(0.0).unwrap(), Some(1.0)); + assert_eq!(tdigest.quantile(0.5).unwrap(), Some(1.0)); + assert_eq!(tdigest.quantile(1.0).unwrap(), Some(1.0)); } #[test] @@ -73,12 +101,12 @@ fn test_empty_split_points_define_one_bin() { let mut tdigest = TDigestMut::new(100).unwrap(); tdigest.update(1.0); - assert_eq!(tdigest.cdf(&[]), Some(vec![1.0])); - assert_eq!(tdigest.pmf(&[]), Some(vec![1.0])); + assert_eq!(tdigest.cdf(&[]).unwrap(), Some(vec![1.0])); + assert_eq!(tdigest.pmf(&[]).unwrap(), Some(vec![1.0])); let tdigest = tdigest.freeze(); - assert_eq!(tdigest.cdf(&[]), Some(vec![1.0])); - assert_eq!(tdigest.pmf(&[]), Some(vec![1.0])); + assert_eq!(tdigest.cdf(&[]).unwrap(), Some(vec![1.0])); + assert_eq!(tdigest.pmf(&[]).unwrap(), Some(vec![1.0])); } #[test] @@ -88,7 +116,7 @@ fn test_maximum_k() { let tdigest = tdigest.freeze(); assert_eq!(tdigest.k(), u16::MAX); - assert_eq!(tdigest.quantile(0.5), Some(1.0)); + assert_eq!(tdigest.quantile(0.5).unwrap(), Some(1.0)); } #[test] @@ -106,14 +134,14 @@ fn test_estimated_size_reuses_buffer_after_compression() { } let size_before_compression = tdigest.estimated_size(); assert!(size_before_compression > inline_size); - tdigest.rank(0.5); + tdigest.rank(0.5).unwrap(); assert!(tdigest.estimated_size() <= size_before_compression); for value in MAX_UNMERGED..10_000 { tdigest.update(value as f64); } let size_before_compression = tdigest.estimated_size(); - tdigest.rank(0.5); + tdigest.rank(0.5).unwrap(); assert!(tdigest.estimated_size() <= size_before_compression); let mut left = TDigestMut::new(K).unwrap(); @@ -158,35 +186,41 @@ fn test_many_values() { assert_eq!(tdigest.min_value(), Some(0.0)); assert_eq!(tdigest.max_value(), Some((n - 1) as f64)); - assert_that!(tdigest.rank(0.0).unwrap(), near(0.0, 0.0001)); - assert_that!(tdigest.rank((n / 4) as f64).unwrap(), near(0.25, 0.0001)); - assert_that!(tdigest.rank((n / 2) as f64).unwrap(), near(0.5, 0.0001)); + assert_that!(tdigest.rank(0.0).unwrap().unwrap(), near(0.0, 0.0001)); + assert_that!( + tdigest.rank((n / 4) as f64).unwrap().unwrap(), + near(0.25, 0.0001) + ); assert_that!( - tdigest.rank((n * 3 / 4) as f64).unwrap(), + tdigest.rank((n / 2) as f64).unwrap().unwrap(), + near(0.5, 0.0001) + ); + assert_that!( + tdigest.rank((n * 3 / 4) as f64).unwrap().unwrap(), near(0.75, 0.0001) ); - assert_that!(tdigest.rank(n as f64).unwrap(), eq(1.0)); - assert_that!(tdigest.quantile(0.0).unwrap(), eq(0.0)); + assert_that!(tdigest.rank(n as f64).unwrap().unwrap(), eq(1.0)); + assert_that!(tdigest.quantile(0.0).unwrap().unwrap(), eq(0.0)); assert_that!( - tdigest.quantile(0.5).unwrap(), + tdigest.quantile(0.5).unwrap().unwrap(), near((n / 2) as f64, 0.03 * (n / 2) as f64) ); assert_that!( - tdigest.quantile(0.9).unwrap(), + tdigest.quantile(0.9).unwrap().unwrap(), near((n as f64) * 0.9, 0.01 * (n as f64) * 0.9) ); assert_that!( - tdigest.quantile(0.95).unwrap(), + tdigest.quantile(0.95).unwrap().unwrap(), near((n as f64) * 0.95, 0.01 * (n as f64) * 0.95) ); - assert_that!(tdigest.quantile(1.0).unwrap(), eq((n - 1) as f64)); + assert_that!(tdigest.quantile(1.0).unwrap().unwrap(), eq((n - 1) as f64)); let split_points = [n as f64 / 2.0]; - let pmf = tdigest.pmf(&split_points).unwrap(); + let pmf = tdigest.pmf(&split_points).unwrap().unwrap(); assert_eq!(pmf.len(), 2); assert_that!(pmf[0], near(0.5, 0.0001)); assert_that!(pmf[1], near(0.5, 0.0001)); - let cdf = tdigest.cdf(&split_points).unwrap(); + let cdf = tdigest.cdf(&split_points).unwrap().unwrap(); assert_eq!(cdf.len(), 2); assert_that!(cdf[0], near(0.5, 0.0001)); assert_that!(cdf[1], eq(1.0)); @@ -197,13 +231,13 @@ fn test_rank_two_values() { let mut tdigest = TDigestMut::new(100).unwrap(); tdigest.update(1.0); tdigest.update(2.0); - assert_eq!(tdigest.rank(0.99), Some(0.0)); - assert_eq!(tdigest.rank(1.0), Some(0.25)); - assert_eq!(tdigest.rank(1.25), Some(0.375)); - assert_eq!(tdigest.rank(1.5), Some(0.5)); - assert_eq!(tdigest.rank(1.75), Some(0.625)); - assert_eq!(tdigest.rank(2.0), Some(0.75)); - assert_eq!(tdigest.rank(2.01), Some(1.0)); + assert_eq!(tdigest.rank(0.99).unwrap(), Some(0.0)); + assert_eq!(tdigest.rank(1.0).unwrap(), Some(0.25)); + assert_eq!(tdigest.rank(1.25).unwrap(), Some(0.375)); + assert_eq!(tdigest.rank(1.5).unwrap(), Some(0.5)); + assert_eq!(tdigest.rank(1.75).unwrap(), Some(0.625)); + assert_eq!(tdigest.rank(2.0).unwrap(), Some(0.75)); + assert_eq!(tdigest.rank(2.01).unwrap(), Some(1.0)); } #[test] @@ -213,9 +247,9 @@ fn test_rank_repeated_values() { tdigest.update(1.0); tdigest.update(1.0); tdigest.update(1.0); - assert_eq!(tdigest.rank(0.99), Some(0.0)); - assert_eq!(tdigest.rank(1.0), Some(0.5)); - assert_eq!(tdigest.rank(1.01), Some(1.0)); + assert_eq!(tdigest.rank(0.99).unwrap(), Some(0.0)); + assert_eq!(tdigest.rank(1.0).unwrap(), Some(0.5)); + assert_eq!(tdigest.rank(1.01).unwrap(), Some(1.0)); } #[test] @@ -225,11 +259,11 @@ fn test_repeated_blocks() { tdigest.update(2.0); tdigest.update(2.0); tdigest.update(3.0); - assert_eq!(tdigest.rank(0.99), Some(0.0)); - assert_eq!(tdigest.rank(1.0), Some(0.125)); - assert_eq!(tdigest.rank(2.0), Some(0.5)); - assert_eq!(tdigest.rank(3.0), Some(0.875)); - assert_eq!(tdigest.rank(3.01), Some(1.0)); + assert_eq!(tdigest.rank(0.99).unwrap(), Some(0.0)); + assert_eq!(tdigest.rank(1.0).unwrap(), Some(0.125)); + assert_eq!(tdigest.rank(2.0).unwrap(), Some(0.5)); + assert_eq!(tdigest.rank(3.0).unwrap(), Some(0.875)); + assert_eq!(tdigest.rank(3.01).unwrap(), Some(1.0)); } #[test] @@ -244,11 +278,11 @@ fn test_merge_small() { assert_eq!(td1.min_value(), Some(1.0)); assert_eq!(td1.max_value(), Some(3.0)); assert_eq!(td1.total_weight(), 4); - assert_eq!(td1.rank(0.99), Some(0.0)); - assert_eq!(td1.rank(1.0), Some(0.125)); - assert_eq!(td1.rank(2.0), Some(0.5)); - assert_eq!(td1.rank(3.0), Some(0.875)); - assert_eq!(td1.rank(3.01), Some(1.0)); + assert_eq!(td1.rank(0.99).unwrap(), Some(0.0)); + assert_eq!(td1.rank(1.0).unwrap(), Some(0.125)); + assert_eq!(td1.rank(2.0).unwrap(), Some(0.5)); + assert_eq!(td1.rank(3.0).unwrap(), Some(0.875)); + assert_eq!(td1.rank(3.01).unwrap(), Some(1.0)); } #[test] @@ -268,11 +302,20 @@ fn test_merge_large() { assert_eq!(td1.min_value(), Some(0.0)); assert_eq!(td1.max_value(), Some((n - 1) as f64)); - assert_that!(td1.rank(0.0).unwrap(), near(0.0, 0.0001)); - assert_that!(td1.rank((n / 4) as f64).unwrap(), near(0.25, 0.0001)); - assert_that!(td1.rank((n / 2) as f64).unwrap(), near(0.5, 0.0001)); - assert_that!(td1.rank((n * 3 / 4) as f64).unwrap(), near(0.75, 0.0001)); - assert_that!(td1.rank(n as f64).unwrap(), eq(1.0)); + assert_that!(td1.rank(0.0).unwrap().unwrap(), near(0.0, 0.0001)); + assert_that!( + td1.rank((n / 4) as f64).unwrap().unwrap(), + near(0.25, 0.0001) + ); + assert_that!( + td1.rank((n / 2) as f64).unwrap().unwrap(), + near(0.5, 0.0001) + ); + assert_that!( + td1.rank((n * 3 / 4) as f64).unwrap().unwrap(), + near(0.75, 0.0001) + ); + assert_that!(td1.rank(n as f64).unwrap().unwrap(), eq(1.0)); } #[test] @@ -319,7 +362,7 @@ fn test_extreme_values_produce_finite_quantiles() { assert_eq!(tdigest.min_value(), Some(-f64::MAX)); assert_eq!(tdigest.max_value(), Some(f64::MAX)); for rank in [0.25, 0.5, 0.75] { - let quantile = tdigest.quantile(rank).unwrap(); + let quantile = tdigest.quantile(rank).unwrap().unwrap(); assert_that!(quantile, is_finite(), "quantile at rank {rank}"); } } @@ -330,7 +373,7 @@ fn test_estimate_repeat_values() { for _ in 0..20 { tdigest.update(1.0); } - assert_eq!(tdigest.quantile(0.9), Some(1.0)); + assert_eq!(tdigest.quantile(0.9).unwrap(), Some(1.0)); } /// Builds a digest whose centroids carry the given weights. @@ -365,11 +408,26 @@ fn test_quantile_moves_toward_the_nearer_bracketing_centroid() { assert_eq!(tdigest.total_weight(), 12); // Ranks 2/12 and 6/12 sit exactly on the two centroids bracketing the first interval. - assert_that!(tdigest.quantile(2.0 / 12.0).unwrap(), near(0.0, 1e-12)); - assert_that!(tdigest.quantile(3.0 / 12.0).unwrap(), near(2.5, 1e-12)); - assert_that!(tdigest.quantile(4.0 / 12.0).unwrap(), near(5.0, 1e-12)); - assert_that!(tdigest.quantile(5.0 / 12.0).unwrap(), near(7.5, 1e-12)); - assert_that!(tdigest.quantile(6.0 / 12.0).unwrap(), near(10.0, 1e-12)); + assert_that!( + tdigest.quantile(2.0 / 12.0).unwrap().unwrap(), + near(0.0, 1e-12) + ); + assert_that!( + tdigest.quantile(3.0 / 12.0).unwrap().unwrap(), + near(2.5, 1e-12) + ); + assert_that!( + tdigest.quantile(4.0 / 12.0).unwrap().unwrap(), + near(5.0, 1e-12) + ); + assert_that!( + tdigest.quantile(5.0 / 12.0).unwrap().unwrap(), + near(7.5, 1e-12) + ); + assert_that!( + tdigest.quantile(6.0 / 12.0).unwrap().unwrap(), + near(10.0, 1e-12) + ); } #[test] @@ -378,11 +436,20 @@ fn test_quantile_right_tail_stays_within_max() { deserialize_with_centroids(100, 0.0, 100.0, &[(10.0, 10), (50.0, 10), (90.0, 10)]); assert_eq!(tdigest.max_value(), Some(100.0)); - assert_that!(tdigest.quantile(0.9).unwrap(), near(95.0, 1e-12)); - assert_that!(tdigest.quantile(29.0 / 30.0).unwrap(), near(100.0, 1e-12)); + assert_that!(tdigest.quantile(0.9).unwrap().unwrap(), near(95.0, 1e-12)); + assert_that!( + tdigest.quantile(29.0 / 30.0).unwrap().unwrap(), + near(100.0, 1e-12) + ); // Mirrors the left tail, which interpolates from min up to the first centroid mean. - assert_that!(tdigest.quantile(1.0 / 30.0).unwrap(), near(0.0, 1e-12)); - assert_that!(tdigest.quantile(5.0 / 30.0).unwrap(), near(10.0, 1e-12)); + assert_that!( + tdigest.quantile(1.0 / 30.0).unwrap().unwrap(), + near(0.0, 1e-12) + ); + assert_that!( + tdigest.quantile(5.0 / 30.0).unwrap().unwrap(), + near(10.0, 1e-12) + ); } #[test] @@ -390,7 +457,7 @@ fn test_quantile_handles_two_sample_last_centroid() { let mut tdigest = deserialize_with_centroids(100, 0.0, 100.0, &[(0.0, 1), (50.0, 1), (90.0, 2)]); - assert_eq!(tdigest.quantile(0.75), Some(100.0)); + assert_eq!(tdigest.quantile(0.75).unwrap(), Some(100.0)); } #[test] @@ -398,13 +465,19 @@ fn test_rank_left_tail_is_a_fraction_of_the_total_weight() { let mut tdigest = deserialize_with_centroids(100, 0.0, 100.0, &[(10.0, 10), (50.0, 10), (90.0, 10)]); - assert_that!(tdigest.rank(5.0).unwrap(), near(0.1, 1e-12)); - assert_that!(tdigest.rank(10.0).unwrap(), near(5.0 / 30.0, 1e-12)); + assert_that!(tdigest.rank(5.0).unwrap().unwrap(), near(0.1, 1e-12)); + assert_that!( + tdigest.rank(10.0).unwrap().unwrap(), + near(5.0 / 30.0, 1e-12) + ); // The right tail is the mirror image and pins the scale the left tail must match. - assert_that!(tdigest.rank(95.0).unwrap(), near(0.9, 1e-12)); - assert_that!(tdigest.rank(90.0).unwrap(), near(25.0 / 30.0, 1e-12)); + assert_that!(tdigest.rank(95.0).unwrap().unwrap(), near(0.9, 1e-12)); + assert_that!( + tdigest.rank(90.0).unwrap().unwrap(), + near(25.0 / 30.0, 1e-12) + ); - let pmf = tdigest.pmf(&[5.0, 95.0]).unwrap(); + let pmf = tdigest.pmf(&[5.0, 95.0]).unwrap().unwrap(); assert_that!(pmf[0], near(0.1, 1e-12)); assert_that!(pmf[1], near(0.8, 1e-12)); assert_that!(pmf[2], near(0.1, 1e-12));