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 cf25bbe  fix: align empty Theta and Tuple seed hash handling (#283)
cf25bbe is described below

commit cf25bbee6e2d2e771c1046763e06406502c800e3
Author: tison <[email protected]>
AuthorDate: Tue Sep 29 23:23:26 2026 +0800

    fix: align empty Theta and Tuple seed hash handling (#283)
---
 CHANGELOG.md                                       |   1 +
 .../src/thetafamily/theta/serialization.rs         |   3 +
 datasketches/src/thetafamily/theta/sketch.rs       | 112 ++++++++++-----------
 .../src/thetafamily/tuple/serialization.rs         |   3 +
 datasketches/src/thetafamily/tuple/sketch.rs       |  36 ++++---
 tests-integration/tests/serde_tests/theta.rs       |  91 +++++++++++++++--
 tests-integration/tests/serde_tests/tuple.rs       |  48 +++++++++
 7 files changed, 209 insertions(+), 85 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index e6614a7..5137769 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,7 @@ All significant changes to this project will be documented in 
this file.
 
 ### Bug fixes
 
+* 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.
 * `CountMinSketch` deserialization now returns `InvalidData` if the total 
absolute weight is negative or any counter's magnitude exceeds it.
diff --git a/datasketches/src/thetafamily/theta/serialization.rs 
b/datasketches/src/thetafamily/theta/serialization.rs
index f9d6e2c..d10917f 100644
--- a/datasketches/src/thetafamily/theta/serialization.rs
+++ b/datasketches/src/thetafamily/theta/serialization.rs
@@ -20,6 +20,9 @@
 pub const UNCOMPRESSED_SERIAL_VERSION: u8 = 3;
 pub const COMPRESSED_SERIAL_VERSION: u8 = 4;
 
+/// Canonical empty compact image, independent of the configured seed.
+pub const EMPTY_SKETCH_BYTES: [u8; 8] = [1, 3, 3, 0, 0, 0x1e, 0, 0];
+
 pub const V2_PREAMBLE_EMPTY: u8 = 1;
 pub const V2_PREAMBLE_PRECISE: u8 = 2;
 pub const V2_PREAMBLE_ESTIMATE: u8 = 3;
diff --git a/datasketches/src/thetafamily/theta/sketch.rs 
b/datasketches/src/thetafamily/theta/sketch.rs
index c293a77..2c87383 100644
--- a/datasketches/src/thetafamily/theta/sketch.rs
+++ b/datasketches/src/thetafamily/theta/sketch.rs
@@ -495,7 +495,7 @@ impl CompactThetaSketch {
         self.compact_state.is_ordered()
     }
 
-    /// Returns the 16-bit seed hash.
+    /// Returns the 16-bit fingerprint of the seed associated with this sketch.
     pub fn seed_hash(&self) -> u16 {
         self.compact_state.seed_hash()
     }
@@ -537,7 +537,7 @@ impl CompactThetaSketch {
             if self.is_estimation_mode() { 2 } else { 1 }
         } else if self.is_estimation_mode() {
             3
-        } else if self.is_empty() || self.num_retained() == 1 {
+        } else if self.num_retained() == 1 {
             1
         } else {
             2
@@ -546,8 +546,7 @@ impl CompactThetaSketch {
 
     /// Serializes this sketch in compressed form if applicable.
     ///
-    /// This uses `serVer = 4` when the sketch is ordered and suitable for 
compression, and falls
-    /// back to uncompressed `serVer = 3` otherwise.
+    /// Falls back to [`serialize`](Self::serialize) when compression is not 
applicable.
     pub fn serialize_compressed(&self) -> Vec<u8> {
         if self.is_suitable_for_compression() {
             self.serialize_v4()
@@ -562,8 +561,14 @@ impl CompactThetaSketch {
             && (self.num_retained() != 1 || self.is_estimation_mode())
     }
 
-    /// Serializes this sketch into the uncompressed compact theta format.
+    /// Serializes this sketch into the uncompressed compact Theta format.
+    ///
+    /// Empty sketches serialize with a zero seed hash.
     pub fn serialize(&self) -> Vec<u8> {
+        if self.is_empty() {
+            return serialization::EMPTY_SKETCH_BYTES.to_vec();
+        }
+
         let retained_hashes = self.retained_hashes();
         let mut bytes = SketchBytes::with_capacity(64 + retained_hashes.len() 
* 8);
 
@@ -576,9 +581,6 @@ impl CompactThetaSketch {
         let mut flags = 0u8;
         flags |= FLAGS_IS_READ_ONLY;
         flags |= FLAGS_IS_COMPACT;
-        if self.is_empty() {
-            flags |= FLAGS_IS_EMPTY;
-        }
         if self.is_ordered() {
             flags |= FLAGS_IS_ORDERED;
         }
@@ -685,22 +687,24 @@ impl CompactThetaSketch {
         bits.div_ceil(8) as u8
     }
 
-    /// Deserializes a compact theta sketch from bytes.
+    /// Deserializes a compact Theta sketch using the default seed.
     ///
     /// # Errors
     ///
-    /// Returns `InvalidData` if the image is malformed or its seed hash does 
not match the default
-    /// seed.
+    /// Returns `InvalidData` if the image is malformed or a non-empty image's 
seed hash does not
+    /// match the default seed.
     pub fn deserialize(bytes: &[u8]) -> Result<Self, Error> {
         Self::deserialize_with_seed(bytes, DEFAULT_UPDATE_SEED)
     }
 
-    /// Deserializes a compact theta sketch from bytes using the provided 
expected seed.
+    /// Deserializes a compact Theta sketch using `seed`.
+    ///
+    /// Empty sketches use the hash of `seed` regardless of the stored seed 
hash.
     ///
     /// # Errors
     ///
-    /// Returns `InvalidData` if the image is malformed, its seed hash does 
not match `seed`, or
-    /// `seed` itself computes to the reserved zero seed hash.
+    /// Returns `InvalidData` if the image is malformed, a non-empty image's 
seed hash does not
+    /// match `seed`, or `seed` itself computes to the reserved zero seed hash.
     pub fn deserialize_with_seed(bytes: &[u8], seed: u64) -> Result<Self, 
Error> {
         let expected_seed_hash = compute_seed_hash(seed, 
ErrorKind::InvalidData)?;
         let mut cursor = SketchSlice::new(bytes);
@@ -809,36 +813,9 @@ impl CompactThetaSketch {
         let seed_hash = cursor
             .read_u16_le()
             .map_err(insufficient_data("seed_hash"))?;
-        check_seed_hash(
-            expected_seed_hash,
-            seed_hash,
-            "deserialized CompactThetaSketch v2",
-            ErrorKind::InvalidData,
-        )?;
-
-        match pre_longs {
-            V2_PREAMBLE_EMPTY => 
Ok(Self::from_compact_state(CompactSketchState::empty(
-                seed_hash,
-            ))),
-            V2_PREAMBLE_PRECISE => {
-                let num_entries = cursor
-                    .read_u32_le()
-                    .map_err(insufficient_data("num_entries"))?
-                    as usize;
-                cursor
-                    .read_u32_le()
-                    .map_err(insufficient_data("<unused_u32>"))?;
-                let entries = Self::read_entries(&mut cursor, num_entries, 
MAX_THETA)?;
-                if num_entries == 0 {
-                    return 
Ok(Self::from_compact_state(CompactSketchState::empty(
-                        seed_hash,
-                    )));
-                }
-                Ok(Self::from_compact_state(CompactSketchState::non_empty(
-                    entries, MAX_THETA, seed_hash, true,
-                )))
-            }
-            V2_PREAMBLE_ESTIMATE => {
+        let (num_entries, theta) = match pre_longs {
+            V2_PREAMBLE_EMPTY => (0, MAX_THETA),
+            V2_PREAMBLE_PRECISE | V2_PREAMBLE_ESTIMATE => {
                 let num_entries = cursor
                     .read_u32_le()
                     .map_err(insufficient_data("num_entries"))?
@@ -846,23 +823,36 @@ impl CompactThetaSketch {
                 cursor
                     .read_u32_le()
                     .map_err(insufficient_data("<unused_u32>"))?;
-                let theta = Self::deserialize_theta(
-                    cursor
-                        .read_u64_le()
-                        .map_err(insufficient_data("theta_long"))?,
-                )?;
-                let entries = Self::read_entries(&mut cursor, num_entries, 
theta)?;
-                if num_entries == 0 && theta == MAX_THETA {
-                    return 
Ok(Self::from_compact_state(CompactSketchState::empty(
-                        seed_hash,
-                    )));
-                }
-                Ok(Self::from_compact_state(CompactSketchState::non_empty(
-                    entries, theta, seed_hash, true,
-                )))
+                let theta = if pre_longs == V2_PREAMBLE_ESTIMATE {
+                    Self::deserialize_theta(
+                        cursor
+                            .read_u64_le()
+                            .map_err(insufficient_data("theta_long"))?,
+                    )?
+                } else {
+                    MAX_THETA
+                };
+                (num_entries, theta)
             }
-            _ => Err(Error::invalid_preamble_longs(&[1, 2, 3], pre_longs)),
+            _ => return Err(Error::invalid_preamble_longs(&[1, 2, 3], 
pre_longs)),
+        };
+
+        if num_entries == 0 && theta == MAX_THETA {
+            return Ok(Self::from_compact_state(CompactSketchState::empty(
+                expected_seed_hash,
+            )));
         }
+
+        check_seed_hash(
+            expected_seed_hash,
+            seed_hash,
+            "deserialized CompactThetaSketch v2",
+            ErrorKind::InvalidData,
+        )?;
+        let entries = Self::read_entries(&mut cursor, num_entries, theta)?;
+        Ok(Self::from_compact_state(CompactSketchState::non_empty(
+            entries, theta, seed_hash, true,
+        )))
     }
 
     fn deserialize_v3(
@@ -881,7 +871,7 @@ impl CompactThetaSketch {
         let empty = (flags & FLAGS_IS_EMPTY) != 0;
         if empty {
             return Ok(Self::from_compact_state(CompactSketchState::empty(
-                seed_hash,
+                expected_seed_hash,
             )));
         }
 
@@ -1021,7 +1011,7 @@ impl CompactThetaSketch {
         let ordered = (flags & FLAGS_IS_ORDERED) != 0;
 
         let compact_state = if empty {
-            CompactSketchState::empty(seed_hash)
+            CompactSketchState::empty(expected_seed_hash)
         } else {
             CompactSketchState::non_empty(entries, theta, seed_hash, ordered)
         };
diff --git a/datasketches/src/thetafamily/tuple/serialization.rs 
b/datasketches/src/thetafamily/tuple/serialization.rs
index 1f4cfee..d44a104 100644
--- a/datasketches/src/thetafamily/tuple/serialization.rs
+++ b/datasketches/src/thetafamily/tuple/serialization.rs
@@ -40,6 +40,9 @@ pub const SKETCH_TYPE: u8 = 1;
 /// Legacy sketch-type byte still accepted on read.
 pub const SKETCH_TYPE_LEGACY: u8 = 5;
 
+/// Canonical empty compact image, independent of the configured seed and 
summary type.
+pub const EMPTY_SKETCH_BYTES: [u8; 8] = [1, 3, 9, 1, 0, 0x1e, 0, 0];
+
 /// Trait for values that can be stored as Tuple sketch summaries.
 ///
 /// Implement this trait for a summary type to make it (de)serializable by
diff --git a/datasketches/src/thetafamily/tuple/sketch.rs 
b/datasketches/src/thetafamily/tuple/sketch.rs
index 2d6b93f..509ef16 100644
--- a/datasketches/src/thetafamily/tuple/sketch.rs
+++ b/datasketches/src/thetafamily/tuple/sketch.rs
@@ -53,6 +53,7 @@ use crate::tuple::hash_table::TupleEntry;
 use crate::tuple::hash_table::TupleHashTable;
 use crate::tuple::policy::SummaryPolicy;
 use crate::tuple::policy::SummaryUpdatePolicy;
+use crate::tuple::serialization::EMPTY_SKETCH_BYTES;
 use crate::tuple::serialization::SERIAL_VERSION;
 use crate::tuple::serialization::SERIAL_VERSION_LEGACY;
 use crate::tuple::serialization::SKETCH_TYPE;
@@ -503,7 +504,7 @@ impl<S> CompactTupleSketch<S> {
         self.compact_state.is_ordered()
     }
 
-    /// Returns the 16-bit seed hash.
+    /// Returns the 16-bit fingerprint of the seed associated with this sketch.
     pub fn seed_hash(&self) -> u16 {
         self.compact_state.seed_hash()
     }
@@ -549,7 +550,7 @@ impl<S> CompactTupleSketch<S> {
     fn preamble_longs(&self) -> u8 {
         if self.is_estimation_mode() {
             3
-        } else if self.is_empty() || self.num_retained() == 1 {
+        } else if self.num_retained() == 1 {
             1
         } else {
             2
@@ -558,9 +559,10 @@ impl<S> CompactTupleSketch<S> {
 
     /// Serializes this sketch into the compact Tuple binary format.
     ///
-    /// Each summary is encoded by its [`TupleSummaryValue`] implementation. 
The layout matches the
-    /// Java/C++ Tuple sketches, so the output can be read by those 
implementations given a
-    /// compatible summary encoding.
+    /// Uses [`TupleSummaryValue`] to encode summaries. Reading the output in 
Java or C++ requires
+    /// a compatible summary encoding.
+    ///
+    /// Empty sketches serialize with a zero seed hash.
     ///
     /// # Examples
     ///
@@ -578,6 +580,10 @@ impl<S> CompactTupleSketch<S> {
     where
         S: TupleSummaryValue,
     {
+        if self.is_empty() {
+            return EMPTY_SKETCH_BYTES.to_vec();
+        }
+
         let retained_entries = self.retained_entries();
         let pre_longs = self.preamble_longs();
         let entries_size: usize = retained_entries
@@ -593,9 +599,6 @@ impl<S> CompactTupleSketch<S> {
         bytes.write_u8(0); // unused
 
         let mut flags = FLAGS_IS_READ_ONLY | FLAGS_IS_COMPACT;
-        if self.is_empty() {
-            flags |= FLAGS_IS_EMPTY;
-        }
         if self.is_ordered() {
             flags |= FLAGS_IS_ORDERED;
         }
@@ -621,8 +624,9 @@ impl<S> CompactTupleSketch<S> {
     ///
     /// # Errors
     ///
-    /// Returns `InvalidData` if the image is malformed, its seed hash does 
not match the default
-    /// seed, or a summary cannot be decoded by `S`.
+    /// Returns `InvalidData` if the image is malformed or a non-empty image's 
seed hash does not
+    /// match the default seed. Also propagates errors from
+    /// [`TupleSummaryValue::deserialize_value`].
     pub fn deserialize(bytes: &[u8]) -> Result<Self, Error>
     where
         S: TupleSummaryValue,
@@ -630,13 +634,15 @@ impl<S> CompactTupleSketch<S> {
         Self::deserialize_with_seed(bytes, DEFAULT_UPDATE_SEED)
     }
 
-    /// Deserializes a compact Tuple sketch using the provided expected `seed`.
+    /// Deserializes a compact Tuple sketch using `seed`.
+    ///
+    /// Empty sketches use the hash of `seed` regardless of the stored seed 
hash.
     ///
     /// # Errors
     ///
-    /// Returns `InvalidData` if the bytes are truncated, the family/serial 
version/sketch type are
-    /// unexpected, the seed hash does not match, the supplied seed computes 
to the reserved zero
-    /// seed hash, or an entry is corrupted.
+    /// Returns `InvalidData` if the image is malformed, a non-empty image's 
seed hash does not
+    /// match `seed`, or `seed` itself computes to the reserved zero seed 
hash. Also propagates
+    /// errors from [`TupleSummaryValue::deserialize_value`].
     pub fn deserialize_with_seed(bytes: &[u8], seed: u64) -> Result<Self, 
Error>
     where
         S: TupleSummaryValue,
@@ -680,7 +686,7 @@ impl<S> CompactTupleSketch<S> {
 
         if empty {
             return Ok(Self::from_compact_state(CompactSketchState::empty(
-                seed_hash,
+                expected_seed_hash,
             )));
         }
 
diff --git a/tests-integration/tests/serde_tests/theta.rs 
b/tests-integration/tests/serde_tests/theta.rs
index 0850f73..b51fc2d 100644
--- a/tests-integration/tests/serde_tests/theta.rs
+++ b/tests-integration/tests/serde_tests/theta.rs
@@ -159,6 +159,62 @@ fn test_go_compatibility() {
     test_sketch_file(path, 0, false);
 }
 
+#[test]
+fn empty_sketch_serialization() {
+    let expected = fs::read(serialization_test_data(
+        "cpp_generated_files",
+        "theta_n0_cpp.sk",
+    ))
+    .unwrap();
+    let sketch = ThetaSketchBuilder::default()
+        .seed(123)
+        .sampling_probability(0.5)
+        .build()
+        .unwrap()
+        .compact(false);
+    assert_eq!(sketch.serialize(), expected);
+    assert_eq!(sketch.serialize_compressed(), expected);
+
+    let other_seed_hash = 
ThetaSketchBuilder::default().build().unwrap().seed_hash();
+    // v3 and v4 empty inputs; v4 includes a one-byte entry count of zero.
+    for mut bytes in [expected, vec![1, 4, 3, 0, 1, 0x1e, 0, 0, 0]] {
+        for stored_seed_hash in [0, other_seed_hash] {
+            bytes[6..8].copy_from_slice(&stored_seed_hash.to_le_bytes());
+            let restored = CompactThetaSketch::deserialize_with_seed(&bytes, 
123).unwrap();
+            assert!(restored.is_empty());
+            assert_eq!(restored.seed_hash(), sketch.seed_hash());
+        }
+    }
+}
+
+#[test]
+fn non_empty_images_without_entries_preserve_seed_hash() {
+    let mut sketch = ThetaSketchBuilder::default()
+        .seed(123)
+        .sampling_probability(1e-12)
+        .build()
+        .unwrap();
+    sketch.update("apple");
+    let compact = sketch.compact(true);
+    assert!(!compact.is_empty());
+    assert_eq!(compact.num_retained(), 0);
+
+    // v2 and v3 share the count/theta layout for sampled sketches.
+    for version in [2, 3] {
+        let mut bytes = compact.serialize();
+        bytes[1] = version;
+        let restored = CompactThetaSketch::deserialize_with_seed(&bytes, 
123).unwrap();
+        assert!(!restored.is_empty());
+        assert_eq!(restored.theta64(), compact.theta64());
+        let err = CompactThetaSketch::deserialize(&bytes).unwrap_err();
+        assert_eq!(err.kind(), ErrorKind::InvalidData);
+
+        bytes[6..8].fill(0);
+        let err = CompactThetaSketch::deserialize_with_seed(&bytes, 
123).unwrap_err();
+        assert_eq!(err.kind(), ErrorKind::InvalidData);
+    }
+}
+
 #[test]
 fn malformed_input_is_rejected() {
     let mut sketch = ThetaSketchBuilder::default().lg_k(5).build().unwrap();
@@ -235,13 +291,30 @@ fn test_v2_exact_non_empty_compatibility() {
 }
 
 #[test]
-fn test_v2_exact_zero_entries_remains_empty() {
-    let sketch = 
CompactThetaSketch::deserialize(&serialize_v2_exact(&[])).unwrap();
-
-    assert!(sketch.is_empty());
-    assert!(!sketch.is_estimation_mode());
-    assert_eq!(sketch.num_retained(), 0);
-    assert_eq!(sketch.estimate(), 0.0);
-    assert_eq!(sketch.lower_bound(NumStdDev::One), 0.0);
-    assert_eq!(sketch.upper_bound(NumStdDev::One), 0.0);
+fn test_v2_java_empty_layouts() {
+    let expected_seed_hash = ThetaSketchBuilder::default()
+        .seed(123)
+        .build()
+        .unwrap()
+        .seed_hash();
+    let other_seed_hash = 
ThetaSketchBuilder::default().build().unwrap().seed_hash();
+
+    // Port of Java 8.0.0 ForwardCompatibilityTest's 
checkSerVer2_{1,2,3}PreLong[s]_Empty:
+    // 
https://github.com/apache/datasketches-java/blob/8.0.0/src/test/java/org/apache/datasketches/theta/ForwardCompatibilityTest.java#L90-L137
+    for pre_longs in [1, 2, 3] {
+        let mut bytes = serialize_v2_exact(&[]);
+        bytes[0] = pre_longs;
+        bytes[5] = 0x0e; // Java's v2 empty flags.
+        bytes.resize(pre_longs as usize * 8, 0);
+        if pre_longs == 3 {
+            bytes[16..24].copy_from_slice(&(i64::MAX as u64).to_le_bytes());
+        }
+
+        for stored_seed_hash in [0, other_seed_hash] {
+            bytes[6..8].copy_from_slice(&stored_seed_hash.to_le_bytes());
+            let sketch = CompactThetaSketch::deserialize_with_seed(&bytes, 
123).unwrap();
+            assert!(sketch.is_empty());
+            assert_eq!(sketch.seed_hash(), expected_seed_hash);
+        }
+    }
 }
diff --git a/tests-integration/tests/serde_tests/tuple.rs 
b/tests-integration/tests/serde_tests/tuple.rs
index fe4c7be..3a71e25 100644
--- a/tests-integration/tests/serde_tests/tuple.rs
+++ b/tests-integration/tests/serde_tests/tuple.rs
@@ -108,6 +108,54 @@ fn test_go_compatibility() {
     }
 }
 
+#[test]
+fn empty_sketch_serialization() {
+    let expected = fs::read(serialization_test_data(
+        "cpp_generated_files",
+        "tuple_int_n0_cpp.sk",
+    ))
+    .unwrap();
+    let sketch = TupleSketchBuilder::new(DefaultUpdatePolicy::<i32>::default())
+        .seed(123)
+        .sampling_probability(0.5)
+        .build()
+        .unwrap()
+        .compact(false);
+    assert_eq!(sketch.serialize(), expected);
+
+    let other_seed_hash = 
TupleSketchBuilder::new(DefaultUpdatePolicy::<i32>::default())
+        .build()
+        .unwrap()
+        .seed_hash();
+    for stored_seed_hash in [0, other_seed_hash] {
+        let mut bytes = expected.clone();
+        bytes[6..8].copy_from_slice(&stored_seed_hash.to_le_bytes());
+        let restored = 
CompactTupleSketch::<i32>::deserialize_with_seed(&bytes, 123).unwrap();
+        assert!(restored.is_empty());
+        assert_eq!(restored.seed_hash(), sketch.seed_hash());
+    }
+}
+
+#[test]
+fn non_empty_images_without_entries_preserve_seed_hash() {
+    let mut sketch = 
TupleSketchBuilder::new(DefaultUpdatePolicy::<i32>::default())
+        .seed(123)
+        .sampling_probability(1e-12)
+        .build()
+        .unwrap();
+    sketch.update("apple", 1);
+    let compact = sketch.compact(true);
+    assert!(!compact.is_empty());
+    assert_eq!(compact.num_retained(), 0);
+
+    let bytes = compact.serialize();
+    let restored = CompactTupleSketch::<i32>::deserialize_with_seed(&bytes, 
123).unwrap();
+    assert!(!restored.is_empty());
+    assert_eq!(restored.theta64(), compact.theta64());
+    let err = CompactTupleSketch::<i32>::deserialize(&bytes).unwrap_err();
+    assert_eq!(err.kind(), ErrorKind::InvalidData);
+}
+
 #[test]
 fn round_trip_preserves_summaries() {
     let mut sketch = 
TupleSketchBuilder::new(DefaultUpdatePolicy::<u64>::default())


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

Reply via email to