This is an automated email from the ASF dual-hosted git repository.

tisonkun pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datasketches-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new 4abc2da  fix: preserve quantile query boundaries and simplify REQ 
(#284)
4abc2da is described below

commit 4abc2dac5c2e4519de177b0f545f9b5d9298d274
Author: tison <[email protected]>
AuthorDate: Sat Oct 3 15:02:58 2026 +0800

    fix: preserve quantile query boundaries and simplify REQ (#284)
---
 CHANGELOG.md                              |   3 +
 README.md                                 |   1 +
 benchmarks/kll/query.rs                   |   6 +-
 benchmarks/req/query.rs                   |   6 +-
 datasketches/src/kll/sketch.rs            |  17 +++-
 datasketches/src/kll/sorted_view.rs       |  22 ++++-
 datasketches/src/req/compactor.rs         | 111 +++++--------------------
 datasketches/src/req/sketch.rs            |  30 ++++---
 datasketches/src/req/sorted_view.rs       | 131 +++++++++---------------------
 tests-integration/tests/kll_test/query.rs |  47 +++++++++++
 tests-integration/tests/req_test/query.rs |  79 ++++++++++++++++++
 11 files changed, 252 insertions(+), 201 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5137769..c9739f2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,7 @@ All significant changes to this project will be documented in 
this file.
 ### Improvements
 
 * Improve the readability of `Debug` output for HLL and CPC sketches and 
unions.
+* `KllSketch::quantile` and `ReqSketch::quantile` are faster at ranks `0.0` 
and `1.0`, with query work independent of the number of retained samples.
 * `BloomFilter::insert` is faster for integer and raw-byte inputs. 
`BloomFilter::contains_and_insert` is also faster when checking already-present 
integer values.
 * `CountMinSketch` updates are faster for integer and raw-byte inputs.
 * `CpcSketch` updates are faster for integer and raw-byte inputs.
@@ -29,6 +30,7 @@ All significant changes to this project will be documented in 
this file.
 
 ### Bug fixes
 
+* `KllSketch` and `ReqSketch` quantile queries, including their sorted views, 
now return the exact stream minimum and maximum at ranks `0.0` and `1.0`, even 
after compaction or when the stream weight exceeds `2^53`.
 * Empty compact Theta and Tuple sketches now serialize with a zero seed hash, 
matching the canonical cross-language encoding. Deserialization associates 
empty sketches with the supplied seed, including legacy Theta v2 images with 
zero or mismatched seed hashes.
 * `CountMinSketch` updates now panic and merges return `InvalidArgument` if 
the total absolute weight would exceed the counter type's maximum. Both leave 
the sketch unchanged, including in release builds.
 * `CountMinSketch::upper_bound` now clamps to the counter type's maximum 
instead of overflowing.
@@ -36,6 +38,7 @@ All significant changes to this project will be documented in 
this file.
 * `FrequentItemsSketch` updates and merges now panic without changing the 
sketch if the total stream weight would overflow, including in release builds.
 * `FrequentItemsSketch` deserialization now returns `InvalidData` if a 
non-empty image declares zero stream weight or the item weights sum to more 
than the declared stream weight.
 * `ReqSketch` updates now panic and merges return `InvalidArgument` if the 
stream weight would exceed `u64::MAX`. Both leave the sketch unchanged, 
including in release builds.
+* `ReqSketch::cdf` and its sorted view now preserve the rank at each split 
point and end at exactly `1.0`, avoiding extra rounding from accumulating PMF 
values.
 * `TDigestMut` updates and merges now panic without changing the digest if the 
total weight would exceed `u64::MAX`, including in release builds.
 * `TDigestMut::merge` now preserves the true minimum and maximum from both 
inputs, including compressed digests.
 * `TDigest` and `TDigestMut` deserialization now returns `InvalidData` for 
invalid flags or extrema, out-of-range values, unsorted centroids, or non-empty 
images with no stored values.
diff --git a/README.md b/README.md
index b7b04ba..a4a0373 100644
--- a/README.md
+++ b/README.md
@@ -70,6 +70,7 @@ Enable multiple algorithms by listing their features 
together, such as `features
 | `cpc`         | `CpcSketch`, `CpcUnion`, `CpcWrapper` | Highly compact 
distinct-count estimation and unions.                                           
   |
 | `frequencies` | `FrequentItemsSketch`                 | Heavy-hitter 
discovery with upper and lower frequency bounds.                                
     |
 | `hll`         | `HllSketch`, `HllUnion`               | Fast distinct-count 
estimation and unions.                                                        |
+| `kll`         | `KllSketch`                           | General-purpose 
quantile, rank, PMF, and CDF queries over totally ordered items.                
    |
 | `req`         | `ReqSketch`                           | Relative-error 
quantile, rank, PMF, and CDF queries with configurable high- or low-rank 
accuracy. |
 | `tdigest`     | `TDigestMut`, `TDigest`               | Quantile and rank 
estimation, with high accuracy near distribution tails.                         
|
 | `theta`       | `ThetaSketch` and set operations      | Distinct counts, set 
expressions, and Jaccard similarity.                                         |
diff --git a/benchmarks/kll/query.rs b/benchmarks/kll/query.rs
index 1c61aa0..508a54b 100644
--- a/benchmarks/kll/query.rs
+++ b/benchmarks/kll/query.rs
@@ -29,10 +29,10 @@ fn rank(bencher: Bencher) {
     bencher.bench_local(|| black_box(&sketch).rank(black_box(&item), 
SearchCriteria::Inclusive));
 }
 
-#[divan::bench]
-fn quantile(bencher: Bencher) {
+#[divan::bench(args = [0.0, 0.5, 1.0])]
+fn quantile(bencher: Bencher, rank: f64) {
     let sketch = prepared_sketch();
-    bencher.bench_local(|| black_box(&sketch).quantile(black_box(0.5), 
SearchCriteria::Inclusive));
+    bencher.bench_local(|| black_box(&sketch).quantile(black_box(rank), 
SearchCriteria::Inclusive));
 }
 
 #[divan::bench]
diff --git a/benchmarks/req/query.rs b/benchmarks/req/query.rs
index b52dfc5..57f4eae 100644
--- a/benchmarks/req/query.rs
+++ b/benchmarks/req/query.rs
@@ -30,11 +30,11 @@ fn rank(bencher: Bencher) {
     bencher.bench_local(|| black_box(&sketch).rank(black_box(&item), 
SearchCriteria::Inclusive));
 }
 
-#[divan::bench]
-fn quantile(bencher: Bencher) {
+#[divan::bench(args = [0.0, 0.5, 1.0])]
+fn quantile(bencher: Bencher, rank: f64) {
     let sketch = prepared_sketch();
 
-    bencher.bench_local(|| black_box(&sketch).quantile(black_box(0.5), 
SearchCriteria::Inclusive));
+    bencher.bench_local(|| black_box(&sketch).quantile(black_box(rank), 
SearchCriteria::Inclusive));
 }
 
 // Intended pattern for repeated queries: build the view once, query it many
diff --git a/datasketches/src/kll/sketch.rs b/datasketches/src/kll/sketch.rs
index 76e76fb..9fb3a62 100644
--- a/datasketches/src/kll/sketch.rs
+++ b/datasketches/src/kll/sketch.rs
@@ -227,6 +227,8 @@ impl<T: Clone + Ord> KllSketch<T> {
 
     /// Returns the quantile for the given normalized rank.
     ///
+    /// Ranks `0.0` and `1.0` return the exact minimum and maximum items of 
the stream.
+    ///
     /// # Errors
     ///
     /// Returns an error if the sketch is empty or `rank` is outside `[0.0, 
1.0]`.
@@ -239,11 +241,19 @@ impl<T: Clone + Ord> KllSketch<T> {
                 "rank must be in [0.0, 1.0], got {rank}"
             )));
         }
+        if rank == 0.0 {
+            return Ok(self.min_item.as_ref().unwrap().clone());
+        }
+        if rank == 1.0 {
+            return Ok(self.max_item.as_ref().unwrap().clone());
+        }
         self.sorted_view().quantile(rank, criteria)
     }
 
     /// Returns approximate quantiles for the given normalized ranks.
     ///
+    /// Ranks `0.0` and `1.0` return the exact minimum and maximum items of 
the stream.
+    ///
     /// The sorted view is built once for the whole batch.
     ///
     /// # Errors
@@ -283,7 +293,12 @@ impl<T: Clone + Ord> KllSketch<T> {
     ///
     /// The view can be reused for repeated queries while this sketch 
continues to receive updates.
     pub fn sorted_view(&self) -> SortedView<T> {
-        build_sorted_view(&self.levels, self.is_level_zero_sorted)
+        build_sorted_view(
+            &self.levels,
+            self.is_level_zero_sorted,
+            self.min_item.clone(),
+            self.max_item.clone(),
+        )
     }
 
     /// Returns the normalized single-sided rank error for the configured k.
diff --git a/datasketches/src/kll/sorted_view.rs 
b/datasketches/src/kll/sorted_view.rs
index b58a69a..d11f45b 100644
--- a/datasketches/src/kll/sorted_view.rs
+++ b/datasketches/src/kll/sorted_view.rs
@@ -28,6 +28,8 @@ use crate::error::Error;
 pub struct SortedView<T: Clone> {
     entries: Vec<Entry<T>>,
     total_weight: u64,
+    min_item: Option<T>,
+    max_item: Option<T>,
 }
 
 #[derive(Debug, Clone)]
@@ -37,7 +39,7 @@ struct Entry<T> {
 }
 
 impl<T: Clone + Ord> SortedView<T> {
-    fn from_sorted(mut entries: Vec<Entry<T>>) -> Self {
+    fn from_sorted(mut entries: Vec<Entry<T>>, min_item: Option<T>, max_item: 
Option<T>) -> Self {
         let mut total_weight = 0u64;
         for entry in &mut entries {
             total_weight += entry.cumulative_weight;
@@ -46,6 +48,8 @@ impl<T: Clone + Ord> SortedView<T> {
         Self {
             entries,
             total_weight,
+            min_item,
+            max_item,
         }
     }
 
@@ -87,6 +91,8 @@ impl<T: Clone + Ord> SortedView<T> {
 
     /// Returns the approximate quantile for `rank`.
     ///
+    /// Ranks `0.0` and `1.0` return the exact minimum and maximum items of 
the stream.
+    ///
     /// # Errors
     ///
     /// Returns an error if the view is empty or `rank` is outside `[0.0, 
1.0]`.
@@ -100,6 +106,14 @@ impl<T: Clone + Ord> SortedView<T> {
             )));
         }
 
+        // Compaction may discard extrema, and large weights can lose 
precision in f64.
+        if rank == 0.0 {
+            return Ok(self.min_item.as_ref().unwrap().clone());
+        }
+        if rank == 1.0 {
+            return Ok(self.max_item.as_ref().unwrap().clone());
+        }
+
         let weight = if criteria == SearchCriteria::Inclusive {
             (rank * self.total_weight as f64).ceil() as u64
         } else {
@@ -116,6 +130,8 @@ impl<T: Clone + Ord> SortedView<T> {
 
     /// Returns approximate quantiles for all `ranks`.
     ///
+    /// Ranks `0.0` and `1.0` return the exact minimum and maximum items of 
the stream.
+    ///
     /// # Errors
     ///
     /// Returns an error if the view is empty or any rank is outside `[0.0, 
1.0]`.
@@ -164,6 +180,8 @@ impl<T: Clone + Ord> SortedView<T> {
 pub fn build_sorted_view<T: Clone + Ord>(
     levels: &[Vec<T>],
     is_level_zero_sorted: bool,
+    min_item: Option<T>,
+    max_item: Option<T>,
 ) -> SortedView<T> {
     let mut runs = Vec::with_capacity(levels.len());
     for (level_index, level) in levels.iter().enumerate() {
@@ -197,7 +215,7 @@ pub fn build_sorted_view<T: Clone + Ord>(
         runs = merged_runs;
     }
 
-    SortedView::from_sorted(runs.pop().unwrap_or_default())
+    SortedView::from_sorted(runs.pop().unwrap_or_default(), min_item, max_item)
 }
 
 fn merge_sorted_entries<T: Ord>(left: Vec<Entry<T>>, right: Vec<Entry<T>>) -> 
Vec<Entry<T>> {
diff --git a/datasketches/src/req/compactor.rs 
b/datasketches/src/req/compactor.rs
index a56c0fa..1a4aa64 100644
--- a/datasketches/src/req/compactor.rs
+++ b/datasketches/src/req/compactor.rs
@@ -17,8 +17,7 @@
 
 //! Compactor implementation for REQ sketch levels.
 //!
-//! Each level in the REQ sketch uses a compactor to maintain a bounded set of 
items
-//! with deterministic compaction when capacity is exceeded.
+//! Each level compacts a selected range, retaining one item from each pair at 
twice the weight.
 
 use crate::common::random::random_bit;
 use crate::error::Error;
@@ -31,31 +30,20 @@ use crate::req::value::ReqValue;
 
 /// A compactor maintains items at a specific level of the REQ sketch.
 ///
-/// When the compactor reaches its nominal capacity, it performs compaction
-/// by keeping approximately half the items and promoting the rest to the next 
level.
+/// Compaction preserves the configured accuracy tail and promotes samples 
from the other tail.
 #[derive(Debug, Clone)]
 pub struct Compactor<T> {
-    /// Current items in the compactor
     items: Vec<T>,
-    /// Whether items are currently sorted
     is_sorted: bool,
-    /// State for deterministic compaction
+    /// Determines which sections are compacted and when their count doubles.
     state: u64,
-    /// Reusable scratch buffer for compaction operations
     scratch_buffer: Vec<T>,
-
-    /// Actual section size (rounded to integer)
     section_size: u32,
-    /// Number of sections in this compactor
     num_sections: u8,
-    /// The level of this compactor (0 = base level)
     lg_weight: u8,
-
-    /// Whether this compactor is configured for high rank accuracy
     rank_accuracy: RankAccuracy,
-    /// Raw section size (maybe fractional)
+    /// Preserves fractional section sizes across successive divisions by 
sqrt(2).
     section_size_raw: f32,
-    /// Random bit for compaction
     coin: bool,
 }
 
@@ -64,11 +52,6 @@ where
     T: Clone + Ord,
 {
     /// Creates a new compactor for the given level.
-    ///
-    /// # Arguments
-    /// * `lg_weight` - The level (log weight) of this compactor
-    /// * `k` - The k parameter from the parent sketch
-    /// * `rank_accuracy` - Rank accuracy configuration
     pub fn new(lg_weight: u8, k: u16, rank_accuracy: RankAccuracy) -> Self {
         let section_size_raw = k as f32;
         let section_size = nearest_even_section_size(section_size_raw);
@@ -156,8 +139,8 @@ where
         }
     }
 
-    /// Merges pre-sorted items into this compactor.
-    /// Merges sorted items into this compactor using scratch buffer to avoid 
allocation.
+    /// Merges sorted items using the reusable scratch buffer.
+    ///
     /// Both this compactor's items and the input must be sorted.
     #[inline(always)]
     pub fn merge_sorted(&mut self, items: &[T]) {
@@ -171,18 +154,13 @@ where
             return;
         }
 
-        // Ensure sorted on both inputs by contract
         let total = self.items.len() + items.len();
         self.scratch_buffer.clear();
-        if self.scratch_buffer.capacity() < total {
-            self.scratch_buffer
-                .reserve(total - self.scratch_buffer.capacity());
-        }
+        self.scratch_buffer.reserve(total);
 
         let (mut i, mut j) = (0usize, 0usize);
         let (a, b) = (&self.items, items);
 
-        // Two-pointer merge into scratch buffer
         while i < a.len() && j < b.len() {
             if a[i] <= b[j] {
                 self.scratch_buffer.push(a[i].clone());
@@ -193,15 +171,9 @@ where
             }
         }
 
-        // Add remaining elements
-        if i < a.len() {
-            self.scratch_buffer.extend_from_slice(&a[i..]);
-        }
-        if j < b.len() {
-            self.scratch_buffer.extend_from_slice(&b[j..]);
-        }
+        self.scratch_buffer.extend_from_slice(&a[i..]);
+        self.scratch_buffer.extend_from_slice(&b[j..]);
 
-        // Swap scratch buffer with items (zero-copy)
         self.items.clear();
         std::mem::swap(&mut self.items, &mut self.scratch_buffer);
         self.is_sorted = true;
@@ -211,62 +183,43 @@ where
     #[inline(always)]
     pub fn sort(&mut self) {
         if !self.is_sorted {
-            // Use unstable sort for better performance (stable not needed for 
REQ sketch)
             self.items.sort_unstable();
             self.is_sorted = true;
         }
     }
 
-    /// Compacts into the provided output buffer without allocating.
-    /// Writes promoted items into `out` and removes the compacted range 
in-place via `copy_within +
-    /// truncate`.
+    /// Writes promoted samples into `out` and removes their source range from 
this level.
     #[inline(always)]
-    pub fn compact_into(&mut self, _rank_accuracy: RankAccuracy, out: &mut 
Vec<T>) {
-        if self.items.is_empty() {
-            out.clear();
-            return;
-        }
-
-        // Sort entire buffer (C++ sorts full buffer before compaction)
+    pub fn compact_into(&mut self, out: &mut Vec<T>) {
+        out.clear();
         self.sort();
 
-        // Calculate sections to compact based on state
         let secs_to_compact =
             ((!self.state).trailing_zeros() + 1).min(self.num_sections as u32) 
as u8;
-        let compaction_range = self.compute_compaction_range(secs_to_compact);
-
-        // Must have at least 2 items to compact
-        if compaction_range.1 <= compaction_range.0 || (compaction_range.1 - 
compaction_range.0) < 2
-        {
-            out.clear();
+        let (start, end) = self.compute_compaction_range(secs_to_compact);
+        if end - start < 2 {
             return;
         }
 
+        // Complement consecutive choices so each pair of compactions promotes 
both parities.
         if (self.state & 1) == 1 {
-            self.coin = !self.coin; // flip coin for odd states
+            self.coin = !self.coin;
         } else {
-            self.coin = random_bit(); // random coin flip for even states
+            self.coin = random_bit();
         }
-        let odds = self.coin;
 
-        // Build promoted items directly into output buffer (no alloc)
-        out.clear();
-        let (start, end) = compaction_range;
-        let mut i = start + if odds { 1 } else { 0 };
+        let mut i = start + usize::from(self.coin);
         while i < end {
-            out.push(self.items[i].clone()); // TODO: use Copy fast-path for 
numeric types
+            out.push(self.items[i].clone());
             i += 2;
         }
 
-        // Remove the compacted range in-place by rotating elements left
         let removed = end - start;
         if end < self.items.len() {
-            // Use rotate_left to move tail elements to fill the gap
             self.items[start..].rotate_left(removed);
         }
         self.items.truncate(self.items.len() - removed);
 
-        // Update state, then ensure enough sections (C++ order)
         self.state = self.state.wrapping_add(1);
         self.ensure_enough_sections();
     }
@@ -286,8 +239,6 @@ where
         1u64 << self.lg_weight
     }
 
-    // Private helper methods
-
     fn ensure_enough_sections(&mut self) -> bool {
         let Some(threshold) = self
             .num_sections
@@ -317,36 +268,18 @@ where
         let mut non_compact = nom_capacity / 2
             + (self.num_sections - secs_to_compact) as usize * 
self.section_size as usize;
 
-        // if (((num_items_ - non_compact) & 1) == 1) ++non_compact;
+        // Preserve an extra item when needed to make the compacted range even.
         if self.items.len() >= non_compact && ((self.items.len() - 
non_compact) & 1) == 1 {
             non_compact += 1;
         }
 
-        let (low, high) = match self.rank_accuracy {
-            RankAccuracy::HighRank => {
-                // HRA: Protect high ranks by compacting LOW sections (low 
values)
-                // This means we compact from [0, num_items - non_compact] 
(bottom end)
-                let high = if self.items.len() >= non_compact {
-                    self.items.len() - non_compact
-                } else {
-                    0
-                };
-                (0, high)
-            }
+        match self.rank_accuracy {
+            RankAccuracy::HighRank => (0, 
self.items.len().saturating_sub(non_compact)),
             RankAccuracy::LowRank => {
-                // LRA: Protect low ranks by compacting HIGH sections (high 
values)
-                // This means we compact from [non_compact, num_items] (top 
end)
                 let low = non_compact.min(self.items.len());
                 (low, self.items.len())
             }
-        };
-
-        // Empty window safety: ensure we have at least 2 items to compact
-        if high <= low || (high - low) < 2 {
-            return (0, 0); // Signal no compaction needed
         }
-
-        (low, high)
     }
 
     /// Serialize this compactor (preamble + items) into the byte buffer.
diff --git a/datasketches/src/req/sketch.rs b/datasketches/src/req/sketch.rs
index 2373b1f..77a3284 100644
--- a/datasketches/src/req/sketch.rs
+++ b/datasketches/src/req/sketch.rs
@@ -198,8 +198,10 @@ where
 
     /// Returns the approximate quantile at the given normalized rank.
     ///
-    /// Builds a transient [`SortedView`] internally. For repeated quantile
-    /// queries, take one snapshot with [`Self::sorted_view`] and query it.
+    /// Ranks `0.0` and `1.0` return the exact minimum and maximum items of 
the stream.
+    ///
+    /// For ranks strictly between `0.0` and `1.0`, builds a transient 
[`SortedView`].
+    /// For repeated queries at those ranks, take one snapshot with 
[`Self::sorted_view`].
     ///
     /// # Errors
     ///
@@ -213,11 +215,19 @@ where
                 "rank {rank} must be in [0, 1]"
             )));
         }
+        if rank == 0.0 {
+            return Ok(self.min_item.as_ref().unwrap().clone());
+        }
+        if rank == 1.0 {
+            return Ok(self.max_item.as_ref().unwrap().clone());
+        }
         self.sorted_view().quantile(rank, criteria)
     }
 
     /// Returns approximate quantiles for the given normalized ranks.
     ///
+    /// Ranks `0.0` and `1.0` return the exact minimum and maximum items of 
the stream.
+    ///
     /// The sorted view is built once and shared across all ranks.
     ///
     /// # Errors
@@ -270,10 +280,10 @@ where
     /// threads) while the sketch keeps receiving updates, and it keeps 
answering
     /// from the state it was taken at.
     ///
-    /// Building the view costs `O(retained · log retained)`; each query on it 
is
-    /// then `O(log retained)`. Prefer taking one view for repeated queries 
over
-    /// calling [`Self::quantile`]/[`Self::pmf`]/[`Self::cdf`], which each 
build a
-    /// transient view.
+    /// Building the view costs `O(retained · log retained)`; rank and 
quantile queries
+    /// then take `O(log retained)`. Prefer reusing one view for repeated 
quantile
+    /// queries at ranks strictly between `0.0` and `1.0`, or repeated
+    /// [`Self::pmf`]/[`Self::cdf`] queries.
     pub fn sorted_view(&self) -> SortedView<T> {
         let mut weighted_items = Vec::with_capacity(self.num_retained as 
usize);
         for compactor in &self.compactors {
@@ -282,7 +292,7 @@ where
                 weighted_items.push((item.clone(), weight));
             }
         }
-        SortedView::new(weighted_items)
+        SortedView::new(weighted_items, self.min_item.clone(), 
self.max_item.clone())
     }
 
     /// Merges another sketch into this one.
@@ -785,14 +795,10 @@ where
     fn compress(&mut self) {
         for h in 0..self.compactors.len() {
             if self.compactors[h].num_items() >= 
self.compactors[h].nominal_capacity() {
-                if h == 0 {
-                    self.compactors[0].sort();
-                }
                 if h + 1 >= self.compactors.len() {
                     self.grow();
                 }
-                self.promotion_buf.clear();
-                self.compactors[h].compact_into(self.rank_accuracy, &mut 
self.promotion_buf);
+                self.compactors[h].compact_into(&mut self.promotion_buf);
                 if !self.promotion_buf.is_empty() {
                     self.compactors[h + 1].sort();
                     self.compactors[h + 1].merge_sorted(&self.promotion_buf);
diff --git a/datasketches/src/req/sorted_view.rs 
b/datasketches/src/req/sorted_view.rs
index 8affbbb..ec62f87 100644
--- a/datasketches/src/req/sorted_view.rs
+++ b/datasketches/src/req/sorted_view.rs
@@ -30,34 +30,23 @@ use crate::error::Error;
 /// repeated quantile/rank queries.
 #[derive(Debug, Clone)]
 pub struct SortedView<T> {
-    /// Items in sorted order
     items: Vec<T>,
-    /// Cumulative weights for each item
     cumulative_weights: Vec<u64>,
-    /// Total weight of all items
     total_weight: u64,
+    min_item: Option<T>,
+    max_item: Option<T>,
 }
 
 impl<T> SortedView<T>
 where
     T: Clone + Ord,
 {
-    /// Creates a new sorted view from weighted items.
-    ///
-    /// # Arguments
-    /// * `weighted_items` - Vector of (item, weight) pairs
-    ///
-    /// The items will be sorted and cumulative weights computed.
-    pub(super) fn new(mut weighted_items: Vec<(T, u64)>) -> Self {
-        if weighted_items.is_empty() {
-            return Self {
-                items: vec![],
-                cumulative_weights: vec![],
-                total_weight: 0,
-            };
-        }
-
-        // Sort by item value - use unstable sort for better performance
+    /// Creates a sorted view, combining the weights of equal items.
+    pub(super) fn new(
+        mut weighted_items: Vec<(T, u64)>,
+        min_item: Option<T>,
+        max_item: Option<T>,
+    ) -> Self {
         weighted_items.sort_unstable_by(|a, b| a.0.cmp(&b.0));
 
         let mut items: Vec<T> = Vec::with_capacity(weighted_items.len());
@@ -82,6 +71,8 @@ where
             items,
             cumulative_weights,
             total_weight: cumulative_weight,
+            min_item,
+            max_item,
         }
     }
 
@@ -90,7 +81,7 @@ where
         self.items.is_empty()
     }
 
-    /// Returns the number of distinct items in the sorted view.
+    /// Returns the number of distinct retained items in the view.
     pub fn len(&self) -> usize {
         self.items.len()
     }
@@ -109,31 +100,21 @@ where
         if self.is_empty() {
             return Err(Error::invalid_argument("sketch is empty"));
         }
-        match criteria {
-            SearchCriteria::Inclusive => {
-                // Find the last position where items[i] <= item
-                // partition_point finds first index where predicate is false
-                let pos = self.items.partition_point(|x| x <= item);
-                if pos == 0 {
-                    Ok(0.0)
-                } else {
-                    Ok(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)
-                } else {
-                    Ok(self.cumulative_weights[pos - 1] as f64 / 
self.total_weight as f64)
-                }
-            }
+        let pos = match criteria {
+            SearchCriteria::Inclusive => self.items.partition_point(|x| x <= 
item),
+            SearchCriteria::Exclusive => self.items.partition_point(|x| x < 
item),
+        };
+        if pos == 0 {
+            Ok(0.0)
+        } else {
+            Ok(self.cumulative_weights[pos - 1] as f64 / self.total_weight as 
f64)
         }
     }
 
     /// Returns the approximate quantile at the given normalized rank.
     ///
+    /// Ranks `0.0` and `1.0` return the exact minimum and maximum items of 
the stream.
+    ///
     /// # Errors
     ///
     /// Returns an error if the view is empty or `rank` is outside `[0.0, 
1.0]`.
@@ -148,43 +129,29 @@ where
             )));
         }
 
-        // Handle edge cases
+        // Compaction may discard extrema, and large weights can lose 
precision in f64.
         if rank == 0.0 {
-            match criteria {
-                SearchCriteria::Inclusive => return Ok(self.items[0].clone()),
-                SearchCriteria::Exclusive => return Ok(self.items[0].clone()),
-            }
+            return Ok(self.min_item.as_ref().unwrap().clone());
         }
         if rank == 1.0 {
-            return Ok(self.items[self.items.len() - 1].clone());
+            return Ok(self.max_item.as_ref().unwrap().clone());
         }
 
-        // Convert rank to target cumulative weight
-        // uint64_t weight = static_cast<uint64_t>(inclusive ? std::ceil(rank 
* total_weight_) :
-        // rank * total_weight_);
         let target_weight = match criteria {
             SearchCriteria::Inclusive => (rank * self.total_weight as 
f64).ceil() as u64,
             SearchCriteria::Exclusive => (rank * self.total_weight as f64) as 
u64,
         };
 
         let index = match criteria {
-            SearchCriteria::Inclusive => {
-                // Equivalent to C++ lower_bound: first index where 
cumulative_weight >= target
-                self.cumulative_weights
-                    .partition_point(|&w| w < target_weight)
-            }
-            SearchCriteria::Exclusive => {
-                // Equivalent to C++ upper_bound: first index where 
cumulative_weight > target
-                self.cumulative_weights
-                    .partition_point(|&w| w <= target_weight)
-            }
+            SearchCriteria::Inclusive => self
+                .cumulative_weights
+                .partition_point(|&w| w < target_weight),
+            SearchCriteria::Exclusive => self
+                .cumulative_weights
+                .partition_point(|&w| w <= target_weight),
         };
 
-        if index >= self.items.len() {
-            return Ok(self.items[self.items.len() - 1].clone());
-        }
-
-        Ok(self.items[index].clone())
+        Ok(self.items[index.min(self.items.len() - 1)].clone())
     }
 
     /// Returns the probability mass function (PMF) over the given split 
points.
@@ -195,24 +162,10 @@ where
     ///
     /// 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<Vec<f64>, Error> {
-        if self.is_empty() {
-            return Err(Error::invalid_argument("sketch is empty"));
-        }
-
-        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)?;
-            result.push(rank - prev_rank);
-            prev_rank = rank;
+        let mut result = self.cdf(split_points, criteria)?;
+        for index in (1..result.len()).rev() {
+            result[index] -= result[index - 1];
         }
-
-        // Add the final interval
-        result.push(1.0 - prev_rank);
-
         Ok(result)
     }
 
@@ -231,21 +184,17 @@ where
         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)?;
-        for mass in pmf {
-            cumulative += mass;
-            result.push(cumulative);
+        for split_point in split_points {
+            result.push(self.rank(split_point, criteria)?);
         }
-
+        result.push(1.0);
         Ok(result)
     }
 
     fn validate_split_points(&self, split_points: &[T]) -> Result<(), Error> {
         if split_points.windows(2).any(|pair| pair[0] >= pair[1]) {
             return Err(Error::invalid_argument(
-                "Split points must be unique and monotonically 
increasing".to_string(),
+                "Split points must be unique and monotonically increasing",
             ));
         }
         Ok(())
@@ -266,7 +215,7 @@ mod tests {
 
     fn create_test_view() -> SortedView<i32> {
         let weighted_items = vec![(1, 1), (3, 1), (5, 1), (7, 1), (9, 1)];
-        SortedView::new(weighted_items)
+        SortedView::new(weighted_items, Some(1), Some(9))
     }
 
     #[test]
@@ -349,7 +298,7 @@ mod tests {
 
     #[test]
     fn test_empty_view() {
-        let view: SortedView<i32> = SortedView::new(vec![]);
+        let view: SortedView<i32> = SortedView::new(vec![], None, None);
         assert!(view.is_empty());
         assert_eq!(view.len(), 0);
         assert_eq!(view.total_weight(), 0);
diff --git a/tests-integration/tests/kll_test/query.rs 
b/tests-integration/tests/kll_test/query.rs
index 5474df9..3730224 100644
--- a/tests-integration/tests/kll_test/query.rs
+++ b/tests-integration/tests/kll_test/query.rs
@@ -22,6 +22,53 @@ use datasketches::kll::KllSketch;
 const DEFAULT_K: u16 = 200;
 const NUMERIC_NOISE_TOLERANCE: f64 = 1e-6;
 
+#[test]
+fn quantile_endpoints_preserve_extrema_after_compaction() {
+    let mut sketch = KllSketch::<i64>::new(8).unwrap();
+    for item in 0..8 {
+        sketch.update(item);
+    }
+    // Compact an even run with distinct extrema before inserting an interior 
item.
+    // Either sampling parity discards one of the two endpoints.
+    sketch.update(3);
+    assert!(sketch.is_estimation_mode());
+    let view = sketch.sorted_view();
+
+    for criteria in [SearchCriteria::Inclusive, SearchCriteria::Exclusive] {
+        assert_eq!(sketch.quantile(0.0, criteria).unwrap(), 0);
+        assert_eq!(sketch.quantile(1.0, criteria).unwrap(), 7);
+        assert_eq!(sketch.quantiles(&[0.0, 1.0], criteria).unwrap(), [0, 7]);
+    }
+
+    sketch.reset();
+    for criteria in [SearchCriteria::Inclusive, SearchCriteria::Exclusive] {
+        assert!(sketch.quantile(0.0, criteria).is_err());
+        assert!(sketch.quantile(1.0, criteria).is_err());
+        assert_eq!(view.quantile(0.0, criteria).unwrap(), 0);
+        assert_eq!(view.quantile(1.0, criteria).unwrap(), 7);
+        assert_eq!(view.quantiles(&[0.0, 1.0], criteria).unwrap(), [0, 7]);
+    }
+}
+
+#[test]
+fn quantile_endpoints_with_large_stream_weight() {
+    let mut sketch = KllSketch::<i64>::new(8).unwrap();
+    sketch.update(0);
+    for _ in 0..53 {
+        sketch.merge(&sketch.clone()).unwrap();
+    }
+    sketch.update(1);
+    assert_eq!(sketch.n(), (1 << 53) + 1);
+    let view = sketch.sorted_view();
+
+    for criteria in [SearchCriteria::Inclusive, SearchCriteria::Exclusive] {
+        assert_eq!(sketch.quantile(0.0, criteria).unwrap(), 0);
+        assert_eq!(sketch.quantile(1.0, criteria).unwrap(), 1);
+        assert_eq!(view.quantile(0.0, criteria).unwrap(), 0);
+        assert_eq!(view.quantile(1.0, criteria).unwrap(), 1);
+    }
+}
+
 #[test]
 fn empty_and_invalid_queries_return_errors() {
     let mut sketch = KllSketch::<i64>::new(DEFAULT_K).unwrap();
diff --git a/tests-integration/tests/req_test/query.rs 
b/tests-integration/tests/req_test/query.rs
index 4fb2e62..bc03743 100644
--- a/tests-integration/tests/req_test/query.rs
+++ b/tests-integration/tests/req_test/query.rs
@@ -19,6 +19,7 @@
 
 use datasketches::common::SearchCriteria;
 use datasketches::error::Error;
+use datasketches::req::RankAccuracy;
 use datasketches::req::ReqSketch;
 use googletest::assert_that;
 use googletest::prelude::all;
@@ -120,6 +121,84 @@ fn pmf_and_cdf_are_consistent() {
     assert_that!(cdf[cdf.len() - 1], near(1.0, 1e-10));
 }
 
+#[test]
+fn cdf_preserves_ranks_and_exact_endpoint() {
+    let mut sketch = ReqSketch::<i64>::default();
+    for item in 1..=70 {
+        sketch.update(item);
+    }
+    assert!(!sketch.is_estimation_mode());
+    let view = sketch.sorted_view();
+
+    for criteria in [SearchCriteria::Inclusive, SearchCriteria::Exclusive] {
+        let offset = i64::from(criteria == SearchCriteria::Exclusive);
+        let splits = [8, 17, 58].map(|item| item + offset);
+        let expected: Vec<_> = splits
+            .iter()
+            .map(|item| sketch.rank(item, criteria).unwrap())
+            .chain(std::iter::once(1.0))
+            .collect();
+
+        assert_eq!(sketch.cdf(&splits, criteria).unwrap(), expected);
+        assert_eq!(view.cdf(&splits, criteria).unwrap(), expected);
+        assert_eq!(view.cdf(&[], criteria).unwrap(), [1.0]);
+    }
+}
+
+#[test]
+fn quantile_endpoints_preserve_extrema_after_compaction() {
+    for accuracy in [RankAccuracy::HighRank, RankAccuracy::LowRank] {
+        let mut sketch = ReqSketch::<i64>::new(4, accuracy).unwrap();
+        // Introduce new extrema across consecutive compactions, whose sampling
+        // parities alternate, so both choices must preserve the exact 
endpoints.
+        for item in 1..=64 {
+            sketch.update(-item);
+            sketch.update(item);
+            let view = sketch.sorted_view();
+
+            for criteria in [SearchCriteria::Inclusive, 
SearchCriteria::Exclusive] {
+                assert_eq!(sketch.quantile(0.0, criteria).unwrap(), -item);
+                assert_eq!(sketch.quantile(1.0, criteria).unwrap(), item);
+                assert_eq!(
+                    sketch.quantiles(&[0.0, 1.0], criteria).unwrap(),
+                    [-item, item]
+                );
+                assert_eq!(view.quantile(0.0, criteria).unwrap(), -item);
+                assert_eq!(view.quantile(1.0, criteria).unwrap(), item);
+            }
+        }
+        assert!(sketch.is_estimation_mode());
+
+        let view = sketch.sorted_view();
+        sketch.reset();
+        for criteria in [SearchCriteria::Inclusive, SearchCriteria::Exclusive] 
{
+            assert!(sketch.quantile(0.0, criteria).is_err());
+            assert!(sketch.quantile(1.0, criteria).is_err());
+            assert_eq!(view.quantile(0.0, criteria).unwrap(), -64);
+            assert_eq!(view.quantile(1.0, criteria).unwrap(), 64);
+        }
+    }
+}
+
+#[test]
+fn quantile_endpoints_with_large_stream_weight() {
+    let mut sketch = ReqSketch::<i64>::default();
+    sketch.update(0);
+    for _ in 0..53 {
+        sketch.merge(&sketch.clone()).unwrap();
+    }
+    sketch.update(1);
+    assert_eq!(sketch.n(), (1 << 53) + 1);
+    let view = sketch.sorted_view();
+
+    for criteria in [SearchCriteria::Inclusive, SearchCriteria::Exclusive] {
+        assert_eq!(sketch.quantile(0.0, criteria).unwrap(), 0);
+        assert_eq!(sketch.quantile(1.0, criteria).unwrap(), 1);
+        assert_eq!(view.quantile(0.0, criteria).unwrap(), 0);
+        assert_eq!(view.quantile(1.0, criteria).unwrap(), 1);
+    }
+}
+
 #[test]
 fn rank_is_monotonic_and_bounded() {
     let mut sketch: ReqSketch<ReqF64> = ReqSketch::default();


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

Reply via email to