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 dd81c80  fix(hll): clamp the HLL-mode lower bound to the non-zero 
register count (#237)
dd81c80 is described below

commit dd81c8041bd8ef991a6f83aa010a895715b90e7b
Author: Jaideep Pyne <[email protected]>
AuthorDate: Fri Aug 28 15:55:27 2026 +0530

    fix(hll): clamp the HLL-mode lower bound to the non-zero register count 
(#237)
    
    Co-authored-by: Claude Opus 4.8 <[email protected]>
    Co-authored-by: tison <[email protected]>
---
 CHANGELOG.md                                       |   1 +
 datasketches/src/hll/estimator.rs                  |  39 +++-
 .../tests/hll_test/{update.rs => bounds.rs}        | 204 ++++-----------------
 tests-integration/tests/hll_test/main.rs           |   1 +
 tests-integration/tests/hll_test/update.rs         | 107 -----------
 5 files changed, 77 insertions(+), 275 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 69f89af..989f148 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -27,6 +27,7 @@ All significant changes to this project will be documented in 
this file.
 * T-Digest compression now supports `k = u16::MAX` without overflowing.
 * T-Digest rejects truncated serialized payloads before allocating, and 
updating a deserialized digest no longer allows its buffered state to grow 
without bound.
 * Compact HLL4 images now restore all register values correctly.
+* `HllSketch::lower_bound` now uses the number of non-zero registers as a 
floor in HLL mode, matching Java, C++, and Go and avoiding a bound below the 
distinct count already proven by register hits.
 * HLL, Theta, and Tuple deserializers now return `InvalidData` for malformed 
payload sizes and entry counts instead of risking oversized allocations or 
decoding failures.
 * Malformed CPC images now return `InvalidData` instead of panicking.
 * Seeded deserializers now return `InvalidData` rather than panicking when the 
caller supplies a seed whose hash is the reserved zero value.
diff --git a/datasketches/src/hll/estimator.rs 
b/datasketches/src/hll/estimator.rs
index e81a859..903e780 100644
--- a/datasketches/src/hll/estimator.rs
+++ b/datasketches/src/hll/estimator.rs
@@ -148,6 +148,9 @@ impl HipEstimator {
     ///
     /// Returns the lower confidence bound for the cardinality estimate.
     ///
+    /// Each non-zero register requires a distinct item, so their count is a 
hard floor
+    /// for the lower bound. If `cur_min` is non-zero, all `k` registers are 
non-zero.
+    ///
     /// # Arguments
     ///
     /// * `lg_config_k`: Log2 of number of registers (k)
@@ -163,8 +166,14 @@ impl HipEstimator {
     ) -> f64 {
         let estimate = self.estimate(lg_config_k, cur_min, num_at_cur_min);
         let rse = get_rel_err(lg_config_k, false, self.out_of_order, 
num_std_dev);
+        let config_k = 1u32 << lg_config_k;
+        let num_nonzero_registers = if cur_min == 0 {
+            config_k - num_at_cur_min
+        } else {
+            config_k
+        };
         // RSE is positive for lower bounds, so (1 + rse) > 1, making bound < 
estimate
-        estimate / (1.0 + rse)
+        (estimate / (1.0 + rse)).max(f64::from(num_nonzero_registers))
     }
 
     /// Get raw HLL estimate using standard HyperLogLog formula
@@ -498,6 +507,34 @@ mod tests {
 
     use super::*;
 
+    #[test]
+    fn lower_bound_is_clamped_to_the_non_zero_register_count() {
+        let lg_config_k = 4;
+        let mut estimator = HipEstimator::new(lg_config_k);
+        for _ in 0..8 {
+            estimator.update(lg_config_k, 0, 1);
+        }
+
+        assert_eq!(
+            estimator.lower_bound(lg_config_k, 0, 8, NumStdDev::Three),
+            8.0
+        );
+    }
+
+    #[test]
+    fn lower_bound_is_clamped_to_config_k_when_every_register_is_hit() {
+        let lg_config_k = 4;
+        let mut estimator = HipEstimator::new(lg_config_k);
+        for _ in 0..16 {
+            estimator.update(lg_config_k, 0, 1);
+        }
+
+        assert_eq!(
+            estimator.lower_bound(lg_config_k, 1, 16, NumStdDev::Three),
+            16.0
+        );
+    }
+
     #[test]
     fn test_estimator_initialization() {
         let est = HipEstimator::new(10); // 1024 registers
diff --git a/tests-integration/tests/hll_test/update.rs 
b/tests-integration/tests/hll_test/bounds.rs
similarity index 52%
copy from tests-integration/tests/hll_test/update.rs
copy to tests-integration/tests/hll_test/bounds.rs
index 4aecd0b..159c01f 100644
--- a/tests-integration/tests/hll_test/update.rs
+++ b/tests-integration/tests/hll_test/bounds.rs
@@ -16,9 +16,9 @@
 // under the License.
 
 use datasketches::common::NumStdDev;
-use datasketches::error::ErrorKind;
 use datasketches::hll::HllSketch;
 use datasketches::hll::HllType;
+use datasketches::hll::HllUnion;
 use googletest::assert_that;
 use googletest::prelude::all;
 use googletest::prelude::ge;
@@ -27,182 +27,52 @@ use googletest::prelude::le;
 use googletest::prelude::lt;
 use googletest::prelude::near;
 
-#[test]
-fn test_basic_update() {
-    let mut sketch = HllSketch::new(12, HllType::Hll8).unwrap();
-
-    // Initially empty
-    assert_eq!(sketch.estimate(), 0.0);
-
-    // Update with some values
-    for i in 0..100 {
-        sketch.update(i);
-    }
-
-    let estimate = sketch.estimate();
-    assert_that!(estimate, gt(0.0));
-    assert_that!(estimate, near(100.0, 20.0));
-}
-
-#[test]
-fn test_list_to_set_promotion() {
-    // Use lg_k=12, which has promotion threshold ~512 for List→Set
-    let mut sketch = HllSketch::new(12, HllType::Hll8).unwrap();
-
-    // Add enough unique values to trigger promotion
-    for i in 0..600 {
-        sketch.update(i);
-    }
-
-    let estimate = sketch.estimate();
-    assert_that!(estimate, near(600.0, 100.0));
-}
-
-#[test]
-fn test_set_to_hll_promotion() {
-    // Use lg_k=10 (K=1024), set promotes at 75% = 768
-    let mut sketch = HllSketch::new(10, HllType::Hll8).unwrap();
-
-    // Add enough values to trigger List→Set→HLL promotions
-    for i in 0..1000 {
-        sketch.update(i);
+fn hll_mode_sketch(lg_k: u8, hll_type: HllType, n: u64) -> HllSketch {
+    let mut sketch = HllSketch::new(lg_k, hll_type).unwrap();
+    for value in 0..n {
+        sketch.update(value);
     }
-
-    let estimate = sketch.estimate();
-    assert_that!(estimate, near(1000.0, 150.0));
+    sketch
 }
 
-#[test]
-fn test_duplicate_handling() {
-    let mut sketch = HllSketch::new(12, HllType::Hll8).unwrap();
-
-    // Add same values multiple times
-    for _ in 0..10 {
-        for i in 0..100 {
-            sketch.update(i);
+fn hll_mode_union(lg_k: u8, hll_type: HllType, n: u64) -> HllSketch {
+    let mut even = HllSketch::new(lg_k, hll_type).unwrap();
+    let mut odd = HllSketch::new(lg_k, hll_type).unwrap();
+    for value in 0..n {
+        if value % 2 == 0 {
+            even.update(value);
+        } else {
+            odd.update(value);
         }
     }
-
-    // Estimate should reflect ~100 unique values, not 1000
-    let estimate = sketch.estimate();
-    assert_that!(estimate, near(100.0, 20.0));
-}
-
-#[test]
-fn test_different_types() {
-    let mut sketch = HllSketch::new(10, HllType::Hll8).unwrap();
-
-    // Mix different types
-    sketch.update(42i32);
-    sketch.update("hello");
-    sketch.update(100u64);
-    sketch.update(true);
-    sketch.update(vec![1, 2, 3]);
-
-    let estimate = sketch.estimate();
-    assert_that!(estimate, ge(5.0));
-}
-
-#[test]
-fn test_hll4_type() {
-    let mut sketch = HllSketch::new(12, HllType::Hll4).unwrap();
-
-    for i in 0..1000 {
-        sketch.update(i);
-    }
-
-    let estimate = sketch.estimate();
-    assert_that!(estimate, near(1000.0, 200.0));
-}
-
-#[test]
-fn test_hll6_type() {
-    let mut sketch = HllSketch::new(12, HllType::Hll6).unwrap();
-
-    for i in 0..1000 {
-        sketch.update(i);
+    let mut union = HllUnion::new(lg_k).unwrap();
+    union.update(&even);
+    union.update(&odd);
+    union.to_sketch(hll_type)
+}
+
+#[test]
+fn hll_mode_lower_bound_matches_cross_language_register_floor() {
+    // C++ reports a floor of 8 for this stream in all three target 
representations.
+    for hll_type in [HllType::Hll4, HllType::Hll6, HllType::Hll8] {
+        let sketch = hll_mode_sketch(4, hll_type, 12);
+        assert_eq!(
+            sketch.lower_bound(NumStdDev::Three),
+            8.0,
+            "type={hll_type:?}"
+        );
     }
 
-    let estimate = sketch.estimate();
-    assert_that!(estimate, near(1000.0, 200.0));
-}
-
-#[test]
-fn test_serialization_roundtrip_after_updates() {
-    let mut sketch1 = HllSketch::new(12, HllType::Hll8).unwrap();
-
-    // Add values and promote through all modes
-    for i in 0..2000 {
-        sketch1.update(i);
-    }
-
-    let estimate1 = sketch1.estimate();
-
-    // Serialize and deserialize
-    let bytes = sketch1.serialize();
-    let sketch2 = HllSketch::deserialize(&bytes).unwrap();
-
-    let estimate2 = sketch2.estimate();
-
-    // Estimates should match after round-trip (allow some numerical error)
-    let relative_error = (estimate1 - estimate2).abs() / estimate1;
+    // The one-standard-deviation bound remains above the register floor.
+    let sketch = hll_mode_sketch(4, HllType::Hll8, 12);
     assert_that!(
-        relative_error,
-        lt(0.05),
-        "estimate1={estimate1}, estimate2={estimate2}"
+        sketch.lower_bound(NumStdDev::One),
+        near(9.946968965192236, 1e-9)
     );
-}
-
-#[test]
-fn test_large_cardinality() {
-    let mut sketch = HllSketch::new(14, HllType::Hll8).unwrap();
-
-    // Add 100K unique values
-    for i in 0..100_000 {
-        sketch.update(i);
-    }
-
-    let estimate = sketch.estimate();
-    let relative_error = (estimate - 100_000.0).abs() / 100_000.0;
-
-    // For lg_k=14, relative error should be ~1.04%
-    assert_that!(relative_error, lt(0.05));
-}
-
-#[test]
-fn test_equals_method() {
-    let mut sketch1 = HllSketch::new(10, HllType::Hll8).unwrap();
-    let mut sketch2 = HllSketch::new(10, HllType::Hll8).unwrap();
-
-    // Both start equal (empty)
-    assert!(sketch1.eq(&sketch2));
 
-    // Add same values to both
-    for i in 0..100 {
-        sketch1.update(i);
-        sketch2.update(i);
-    }
-
-    // Should still be equal
-    assert!(sketch1.eq(&sketch2));
-
-    // Add different value to sketch2
-    sketch2.update(999);
-
-    // Now they're different
-    assert!(!sketch1.eq(&sketch2));
-}
-
-#[test]
-fn test_invalid_lg_k_low_returns_error() {
-    let error = HllSketch::new(3, HllType::Hll8).unwrap_err();
-    assert_eq!(error.kind(), ErrorKind::InvalidArgument);
-}
-
-#[test]
-fn test_invalid_lg_k_high_returns_error() {
-    let error = HllSketch::new(22, HllType::Hll8).unwrap_err();
-    assert_eq!(error.kind(), ErrorKind::InvalidArgument);
+    // The same floor applies when a union selects the out-of-order estimator.
+    let sketch = hll_mode_union(7, HllType::Hll4, 40);
+    assert_eq!(sketch.lower_bound(NumStdDev::Three), 34.0);
 }
 
 #[test]
diff --git a/tests-integration/tests/hll_test/main.rs 
b/tests-integration/tests/hll_test/main.rs
index e56cecc..30fb7dc 100644
--- a/tests-integration/tests/hll_test/main.rs
+++ b/tests-integration/tests/hll_test/main.rs
@@ -15,5 +15,6 @@
 // specific language governing permissions and limitations
 // under the License.
 
+mod bounds;
 mod union;
 mod update;
diff --git a/tests-integration/tests/hll_test/update.rs 
b/tests-integration/tests/hll_test/update.rs
index 4aecd0b..50fd0b4 100644
--- a/tests-integration/tests/hll_test/update.rs
+++ b/tests-integration/tests/hll_test/update.rs
@@ -15,15 +15,12 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use datasketches::common::NumStdDev;
 use datasketches::error::ErrorKind;
 use datasketches::hll::HllSketch;
 use datasketches::hll::HllType;
 use googletest::assert_that;
-use googletest::prelude::all;
 use googletest::prelude::ge;
 use googletest::prelude::gt;
-use googletest::prelude::le;
 use googletest::prelude::lt;
 use googletest::prelude::near;
 
@@ -204,107 +201,3 @@ fn test_invalid_lg_k_high_returns_error() {
     let error = HllSketch::new(22, HllType::Hll8).unwrap_err();
     assert_eq!(error.kind(), ErrorKind::InvalidArgument);
 }
-
-#[test]
-fn test_bounds_basic() {
-    let mut sketch = HllSketch::new(12, HllType::Hll8).unwrap();
-
-    // Add 1000 unique values
-    for i in 0..1000 {
-        sketch.update(i);
-    }
-
-    let estimate = sketch.estimate();
-    let upper1 = sketch.upper_bound(NumStdDev::One);
-    let lower1 = sketch.lower_bound(NumStdDev::One);
-    let upper2 = sketch.upper_bound(NumStdDev::Two);
-    let lower2 = sketch.lower_bound(NumStdDev::Two);
-    let upper3 = sketch.upper_bound(NumStdDev::Three);
-    let lower3 = sketch.lower_bound(NumStdDev::Three);
-
-    // Basic sanity checks
-    assert_that!(estimate, ge(lower1));
-    assert_that!(estimate, le(upper1));
-
-    // Bounds should widen with more standard deviations
-    assert_that!(lower2, le(lower1));
-    assert_that!(upper1, le(upper2));
-    assert_that!(lower3, le(lower2));
-    assert_that!(upper2, le(upper3));
-
-    // Bounds should be reasonable (within 50% for 3-sigma)
-    assert_that!(lower3, gt(estimate * 0.5));
-    assert_that!(upper3, lt(estimate * 1.5));
-}
-
-#[test]
-fn test_bounds_all_modes() {
-    // Test List mode (small cardinality)
-    let mut sketch = HllSketch::new(12, HllType::Hll8).unwrap();
-    for i in 0..10 {
-        sketch.update(i);
-    }
-    let estimate = sketch.estimate();
-    let upper = sketch.upper_bound(NumStdDev::Two);
-    let lower = sketch.lower_bound(NumStdDev::Two);
-    assert_that!(estimate, all!(ge(lower), le(upper)), "mode: LIST");
-
-    // Test Set mode (medium cardinality)
-    for i in 10..100 {
-        sketch.update(i);
-    }
-    let estimate = sketch.estimate();
-    let upper = sketch.upper_bound(NumStdDev::Two);
-    let lower = sketch.lower_bound(NumStdDev::Two);
-    assert_that!(estimate, all!(ge(lower), le(upper)), "mode: SET");
-
-    // Test HLL mode (large cardinality)
-    for i in 100..5000 {
-        sketch.update(i);
-    }
-    let estimate = sketch.estimate();
-    let upper = sketch.upper_bound(NumStdDev::Two);
-    let lower = sketch.lower_bound(NumStdDev::Two);
-    assert_that!(estimate, all!(ge(lower), le(upper)), "mode: HLL");
-}
-
-#[test]
-fn test_bounds_different_lg_k() {
-    // Smaller lg_k should have wider bounds (higher RSE)
-    let mut sketch_small = HllSketch::new(8, HllType::Hll8).unwrap(); // 
lg_k=8, k=256
-    let mut sketch_large = HllSketch::new(14, HllType::Hll8).unwrap(); // 
lg_k=14, k=16384
-
-    for i in 0..1000 {
-        sketch_small.update(i);
-        sketch_large.update(i);
-    }
-
-    let est_small = sketch_small.estimate();
-    let est_large = sketch_large.estimate();
-
-    let upper_small = sketch_small.upper_bound(NumStdDev::Two);
-    let lower_small = sketch_small.lower_bound(NumStdDev::Two);
-    let upper_large = sketch_large.upper_bound(NumStdDev::Two);
-    let lower_large = sketch_large.lower_bound(NumStdDev::Two);
-
-    // Calculate relative width of confidence intervals
-    let width_small = (upper_small - lower_small) / est_small;
-    let width_large = (upper_large - lower_large) / est_large;
-
-    // Smaller sketch should have wider relative confidence interval
-    assert_that!(width_small, gt(width_large));
-}
-
-#[test]
-fn test_bounds_empty_sketch() {
-    let sketch = HllSketch::new(12, HllType::Hll8).unwrap();
-
-    let estimate = sketch.estimate();
-    let upper = sketch.upper_bound(NumStdDev::Two);
-    let lower = sketch.lower_bound(NumStdDev::Two);
-
-    assert_eq!(estimate, 0.0, "Empty sketch should have 0 estimate");
-    assert_that!(lower, ge(0.0));
-    assert_that!(upper, ge(0.0));
-    assert_that!(lower, le(upper));
-}


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

Reply via email to