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 2a19b8d  fix(countmin): preserve integer precision during scaling 
(#285)
2a19b8d is described below

commit 2a19b8da2b92e19554a5c7a6d8a110f107189226
Author: Jaideep Pyne <[email protected]>
AuthorDate: Sat Oct 3 19:48:08 2026 +0530

    fix(countmin): preserve integer precision during scaling (#285)
    
    Co-authored-by: tison <[email protected]>
---
 CHANGELOG.md                                    |  2 +
 benchmarks/countmin/{mod.rs => decay.rs}        | 16 ++++-
 benchmarks/countmin/mod.rs                      |  1 +
 datasketches/src/countmin/sketch.rs             | 29 +++++----
 datasketches/src/countmin/value.rs              | 32 +++++++--
 tests-integration/tests/countmin_test/sketch.rs | 87 +++++++++++++++++++++++++
 6 files changed, 149 insertions(+), 18 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index c9739f2..ff73f71 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,7 @@ All significant changes to this project will be documented in 
this file.
 * `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.
+* `CountMinSketch::decay(1.0)` now takes constant time.
 * `CpcSketch` updates are faster for integer and raw-byte inputs.
 * `FrequentItemsSketch` updates are faster for integer and raw-byte keys.
 * `HllSketch` updates are faster for integer and raw-byte inputs.
@@ -30,6 +31,7 @@ All significant changes to this project will be documented in 
this file.
 
 ### Bug fixes
 
+* `CountMinSketch::decay` and `upper_bound` now scale integer weights without 
intermediate floating-point rounding, including signed error bounds. Products 
use the exact binary value of the floating-point factor and truncate toward 
zero; for example, `decay(0.99)` changes a counter of `100` to `98` rather than 
`99`.
 * `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.
diff --git a/benchmarks/countmin/mod.rs b/benchmarks/countmin/decay.rs
similarity index 63%
copy from benchmarks/countmin/mod.rs
copy to benchmarks/countmin/decay.rs
index 1dea33b..82de772 100644
--- a/benchmarks/countmin/mod.rs
+++ b/benchmarks/countmin/decay.rs
@@ -15,4 +15,18 @@
 // specific language governing permissions and limitations
 // under the License.
 
-mod update;
+use datasketches::countmin::CountMinSketch;
+use divan::Bencher;
+use divan::black_box;
+
+#[divan::bench(args = [0.5, 0.99, 1.0])]
+fn u64(bencher: Bencher, factor: f64) {
+    let mut sketch = CountMinSketch::<u64>::new(4, 16_384).unwrap();
+    for item in 0..128_u64 {
+        sketch.update_with_weight(item, (1 << 53) + item);
+    }
+
+    bencher
+        .with_inputs(|| sketch.clone())
+        .bench_local_refs(|sketch| black_box(sketch).decay(black_box(factor)));
+}
diff --git a/benchmarks/countmin/mod.rs b/benchmarks/countmin/mod.rs
index 1dea33b..375b6d8 100644
--- a/benchmarks/countmin/mod.rs
+++ b/benchmarks/countmin/mod.rs
@@ -15,4 +15,5 @@
 // specific language governing permissions and limitations
 // under the License.
 
+mod decay;
 mod update;
diff --git a/datasketches/src/countmin/sketch.rs 
b/datasketches/src/countmin/sketch.rs
index bffdca1..6c02dc0 100644
--- a/datasketches/src/countmin/sketch.rs
+++ b/datasketches/src/countmin/sketch.rs
@@ -132,7 +132,7 @@ impl<T: CountMinValue> CountMinSketch<T> {
         std::f64::consts::E / self.num_buckets as f64
     }
 
-    /// Returns `true` if the sketch has not seen any updates.
+    /// Returns `true` if the total weight is zero.
     pub fn is_empty(&self) -> bool {
         self.total_weight == T::ZERO
     }
@@ -260,11 +260,9 @@ impl<T: CountMinValue> CountMinSketch<T> {
     }
 
     /// Returns the upper bound on the true frequency of the given item.
-    ///
-    /// Clamps the bound to `T::MAX` if adding the error would overflow.
     pub fn upper_bound<I: Hash>(&self, item: I) -> T {
         let estimate = self.estimate(item);
-        let error = self.total_weight.scale(self.relative_error());
+        let error = self.total_weight.scale_nonnegative(self.relative_error());
         estimate.checked_add(error).unwrap_or(T::MAX)
     }
 
@@ -514,9 +512,7 @@ impl<T: CountMinValue> CountMinSketch<T> {
 }
 
 impl<T: UnsignedCountMinValue> CountMinSketch<T> {
-    /// Divides every counter by two, truncating toward zero.
-    ///
-    /// Useful for exponential decay where counts represent recent activity.
+    /// Halves all counters and the total weight, rounding down.
     ///
     /// # Examples
     ///
@@ -535,10 +531,12 @@ impl<T: UnsignedCountMinValue> CountMinSketch<T> {
         self.total_weight = self.total_weight.halve();
     }
 
-    /// Multiplies every counter by `decay` and truncates back into `T`.
+    /// Scales all counters and the total weight by `decay`, rounding down.
     ///
-    /// Values are truncated toward zero after multiplication; choose `decay` 
in `(0, 1]`.
-    /// The total weight is scaled by the same factor to keep bounds 
consistent.
+    /// Rounding uses the actual `f64` value of the factor, so `decay(0.99)` 
changes a
+    /// count of `100` to `98`.
+    ///
+    /// A factor of `1.0` leaves the sketch unchanged; `0.5` is equivalent to 
[`Self::halve`].
     ///
     /// # Panics
     ///
@@ -556,10 +554,17 @@ impl<T: UnsignedCountMinValue> CountMinSketch<T> {
     /// ```
     pub fn decay(&mut self, decay: f64) {
         assert!(decay > 0.0 && decay <= 1.0, "decay must be within (0, 1]");
+        if decay == 1.0 {
+            return;
+        }
+        if decay == 0.5 {
+            self.halve();
+            return;
+        }
         for c in &mut self.counts {
-            *c = c.scale(decay)
+            *c = c.scale_nonnegative(decay)
         }
-        self.total_weight = self.total_weight.scale(decay);
+        self.total_weight = self.total_weight.scale_nonnegative(decay);
     }
 }
 
diff --git a/datasketches/src/countmin/value.rs 
b/datasketches/src/countmin/value.rs
index 6e9ec93..93d78e1 100644
--- a/datasketches/src/countmin/value.rs
+++ b/datasketches/src/countmin/value.rs
@@ -39,7 +39,7 @@ mod private {
 
         fn checked_abs(self) -> Option<Self>;
         fn checked_add(self, other: Self) -> Option<Self>;
-        fn scale(self, factor: f64) -> Self;
+        fn scale_nonnegative(self, factor: f64) -> Self;
         fn to_bytes(self) -> [u8; 8];
         fn try_from_bytes(bytes: [u8; 8]) -> Result<Self, Error>;
     }
@@ -49,6 +49,27 @@ mod private {
     }
 }
 
+#[inline]
+fn scale_u64(value: u64, factor: f64) -> u64 {
+    debug_assert!(factor > 0.0 && factor <= 1.0);
+
+    // Decode factor = significand / 2^shift. The product needs at most
+    // 64 + 53 bits, so u128 preserves it exactly until the final truncation.
+    let bits = factor.to_bits();
+    let exponent = ((bits >> 52) & 0x7ff) as u32;
+    let fraction = bits & ((1_u64 << 52) - 1);
+    // Normal values have an implicit leading bit and exponent bias 1023;
+    // subnormals have no leading bit and use a fixed exponent of -1074.
+    let (significand, shift) = if exponent == 0 {
+        (fraction, 1074)
+    } else {
+        (fraction | (1_u64 << 52), 1075 - exponent)
+    };
+    (u128::from(value) * u128::from(significand))
+        .checked_shr(shift)
+        .unwrap_or(0) as u64
+}
+
 macro_rules! impl_signed {
     ($name:ty, $min:expr, $max:expr) => {
         impl private::CountMinValue for $name {
@@ -67,8 +88,9 @@ macro_rules! impl_signed {
             }
 
             #[inline(always)]
-            fn scale(self, factor: f64) -> Self {
-                ((self as f64) * factor).trunc() as $name
+            fn scale_nonnegative(self, factor: f64) -> Self {
+                debug_assert!(self >= 0);
+                scale_u64(self as u64, factor) as $name
             }
 
             #[inline(always)]
@@ -118,8 +140,8 @@ macro_rules! impl_unsigned {
             }
 
             #[inline(always)]
-            fn scale(self, factor: f64) -> Self {
-                ((self as f64) * factor).trunc() as $name
+            fn scale_nonnegative(self, factor: f64) -> Self {
+                scale_u64(self as u64, factor) as $name
             }
 
             #[inline(always)]
diff --git a/tests-integration/tests/countmin_test/sketch.rs 
b/tests-integration/tests/countmin_test/sketch.rs
index 785b86e..ba2ab67 100644
--- a/tests-integration/tests/countmin_test/sketch.rs
+++ b/tests-integration/tests/countmin_test/sketch.rs
@@ -19,6 +19,7 @@ use std::panic::AssertUnwindSafe;
 use std::panic::catch_unwind;
 
 use datasketches::countmin::CountMinSketch;
+use datasketches::countmin::UnsignedCountMinValue;
 use datasketches::error::ErrorKind;
 use googletest::assert_that;
 use googletest::prelude::ge;
@@ -75,6 +76,26 @@ fn upper_bound_clamps_on_overflow() {
     assert_eq!(signed.upper_bound("x"), i8::MAX);
 }
 
+#[test]
+fn upper_bound_preserves_large_integer_precision_for_both_counter_types() {
+    let mut signed = CountMinSketch::<i64>::new(3, 128).unwrap();
+    let mut unsigned = CountMinSketch::<u64>::new(3, 128).unwrap();
+    signed.update_with_weight("item", i64::MAX);
+    unsigned.update_with_weight("item", i64::MAX as u64);
+    assert_eq!(signed.estimate("missing"), 0);
+    assert_eq!(unsigned.estimate("missing"), 0);
+
+    // Multiplying this epsilon by 2^63 gives an exactly representable integer.
+    // Subtracting one from the weight subtracts epsilon, so truncation must 
drop one.
+    let error = (signed.relative_error() * (1_u64 << 63) as f64) as u64 - 1;
+    assert_eq!(signed.upper_bound("missing"), error as i64);
+    assert_eq!(unsigned.upper_bound("missing"), error);
+
+    let mut negative = CountMinSketch::<i64>::new(3, 128).unwrap();
+    negative.update_with_weight("item", -i64::MAX);
+    assert_eq!(negative.upper_bound("item"), -i64::MAX + error as i64);
+}
+
 #[test]
 fn test_init_defaults() {
     let sketch = CountMinSketch::<i64>::new(3, 5).unwrap();
@@ -261,6 +282,72 @@ fn test_decay() {
     }
 }
 
+#[test]
+fn test_decay_preserves_large_integer_precision() {
+    for weight in [(1_u64 << 53) + 1, u64::MAX - 1, u64::MAX] {
+        for (factor, expected) in [
+            (1.0, weight),
+            (0.5, weight / 2),
+            (0.75, ((u128::from(weight) * 3) / 4) as u64),
+            (1.0_f64.next_down(), weight - weight.div_ceil(1 << 53)),
+            (2.0_f64.powi(-63), weight >> 63),
+            (2.0_f64.powi(-64), 0),
+            (f64::from_bits(1), 0),
+        ] {
+            let mut sketch = CountMinSketch::<u64>::new(3, 128).unwrap();
+            sketch.update_with_weight("item", weight);
+            sketch.decay(factor);
+            assert_eq!(
+                sketch.total_weight(),
+                expected,
+                "weight={weight}, factor={factor}"
+            );
+            assert_eq!(sketch.estimate("item"), expected);
+            let restored = 
CountMinSketch::<u64>::deserialize(&sketch.serialize()).unwrap();
+            assert_eq!(restored.total_weight(), expected);
+        }
+    }
+}
+
+#[test]
+fn decay_truncates_the_exact_binary_product() {
+    for (weight, factor, expected) in [
+        (100, 0.99, 98),
+        (100, 0.99_f64.next_up(), 99),
+        (10, 0.3, 2),
+        (10, 0.3_f64.next_up(), 3),
+    ] {
+        let mut sketch = CountMinSketch::<u64>::new(3, 128).unwrap();
+        sketch.update_with_weight("item", weight);
+        sketch.decay(factor);
+        assert_eq!(sketch.total_weight(), expected);
+        assert_eq!(sketch.estimate("item"), expected);
+    }
+}
+
+#[test]
+fn decay_identity_and_halving_preserve_the_whole_sketch() {
+    fn check<T: UnsignedCountMinValue + std::fmt::Debug>(weights: [T; 3]) {
+        let mut sketch = CountMinSketch::<T>::new(3, 128).unwrap();
+        for (item, weight) in weights.into_iter().enumerate() {
+            sketch.update_with_weight(item, weight);
+        }
+
+        let mut decayed = sketch.clone();
+        decayed.decay(1.0);
+        assert_eq!(decayed, sketch);
+
+        decayed.decay(0.5);
+        sketch.halve();
+        assert_eq!(decayed, sketch);
+    }
+
+    check([u8::MAX / 2, u8::MAX / 4, 1]);
+    check([u16::MAX / 2, u16::MAX / 4, 1]);
+    check([u32::MAX / 2, u32::MAX / 4, 1]);
+    check([u64::MAX / 2, u64::MAX / 4, 1]);
+}
+
 #[test]
 fn test_merge() {
     let mut left = CountMinSketch::<i64>::new(3, 64).unwrap();


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

Reply via email to