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 612ffb2  feat(bloom): replace invert with a set difference operation 
(#278)
612ffb2 is described below

commit 612ffb229748276037067eb8a915f8bb55a7e8a0
Author: tison <[email protected]>
AuthorDate: Wed Sep 16 12:45:19 2026 +0800

    feat(bloom): replace invert with a set difference operation (#278)
---
 CHANGELOG.md                                 |  2 +
 README.md                                    |  2 +-
 datasketches/Cargo.toml                      |  4 ++
 datasketches/examples/AGENTS.md              |  7 +++
 datasketches/examples/bloom_difference.rs    | 89 ++++++++++++++++++++++++++++
 datasketches/src/bloom/mod.rs                | 12 ++--
 datasketches/src/bloom/sketch.rs             | 68 +++++++++++++++------
 tests-integration/tests/bloom_test/sketch.rs | 84 ++++++++++++++++++++++----
 8 files changed, 233 insertions(+), 35 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 29ceccf..fcfb676 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,10 +6,12 @@ All significant changes to this project will be documented in 
this file.
 
 ### Breaking changes
 
+* Remove `BloomFilter::invert`. Bit inversion has no sound set-membership 
interpretation; use the new `BloomFilter::difference` for the approximate 
set-difference (A NOT B) use case it was meant to serve.
 * Move `SearchCriteria` from `req` to `common` and remove its `Default` 
implementation. Import `datasketches::common::SearchCriteria` and explicitly 
choose `Inclusive` or `Exclusive` for each query.
 
 ### New features
 
+* Add `BloomFilter::difference` for approximate set difference: the result 
excludes the other filter's items exactly, while items unique to the left 
filter are kept unless their hash positions collide with the right filter.
 * Add KLL sketches behind the `kll` feature, with rank, quantile, PMF, and CDF 
queries, merging, totally ordered custom item types, a `KllFloat` adapter for 
non-NaN floating-point values, and serialization.
 
 ### Improvements
diff --git a/README.md b/README.md
index 3143336..b7b04ba 100644
--- a/README.md
+++ b/README.md
@@ -75,7 +75,7 @@ Enable multiple algorithms by listing their features 
together, such as `features
 | `theta`       | `ThetaSketch` and set operations      | Distinct counts, set 
expressions, and Jaccard similarity.                                         |
 | `tuple`       | `TupleSketch` and set operations      | Theta-style keys 
with user-defined summaries attached to retained entries.                       
 |
 
-See the [API documentation](https://docs.rs/datasketches) for configuration, 
accuracy guarantees, serialization, and examples for each algorithm.
+See the [API documentation](https://docs.rs/datasketches) for configuration, 
accuracy guarantees, serialization, and examples for each algorithm. Runnable 
end-to-end scenarios live in [`datasketches/examples`](datasketches/examples).
 
 ## Compatibility
 
diff --git a/datasketches/Cargo.toml b/datasketches/Cargo.toml
index 1a31d98..0549005 100644
--- a/datasketches/Cargo.toml
+++ b/datasketches/Cargo.toml
@@ -49,6 +49,10 @@ tdigest = []
 theta = []
 tuple = []
 
+[[example]]
+name = "bloom_difference"
+required-features = ["bloom"]
+
 [dev-dependencies]
 googletest = { workspace = true }
 insta = { workspace = true }
diff --git a/datasketches/examples/AGENTS.md b/datasketches/examples/AGENTS.md
new file mode 100644
index 0000000..c5c2448
--- /dev/null
+++ b/datasketches/examples/AGENTS.md
@@ -0,0 +1,7 @@
+# Example Style
+
+- Start each example with concise module documentation describing the scenario 
and the behavior it demonstrates.
+- Omit run commands, basic Cargo instructions, and comments that merely 
restate the code. Assume readers know how to run a Rust example.
+- Keep each example focused on a small, coherent scenario. Explain relevant 
semantic differences beside the code; avoid catalogs of unrelated primitives.
+- Keep example explanations in the example source. The repository README only 
needs a concise entry point.
+- Register each example in `datasketches/Cargo.toml` with an explicit 
`[[example]]` section and its `required-features`, since every sketch feature 
is opt-in.
diff --git a/datasketches/examples/bloom_difference.rs 
b/datasketches/examples/bloom_difference.rs
new file mode 100644
index 0000000..ad8d375
--- /dev/null
+++ b/datasketches/examples/bloom_difference.rs
@@ -0,0 +1,89 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! An edge node folds the origin's purge list into its cache picture with
+//! `BloomFilter::difference`, producing one "everything it still serves" 
filter to ship to
+//! sibling nodes. Purged objects are excluded exactly — a takedown is never 
served again —
+//! while a few still-valid objects drop out of the filter and become origin 
refetches.
+
+use datasketches::bloom::BloomFilter;
+use datasketches::bloom::BloomFilterBuilder;
+
+/// Returns the shared filter configuration published by the origin.
+///
+/// Both sides of a difference must use identical capacity, hash count, and 
seed, so the
+/// parameters are distributed rather than chosen independently by each node.
+fn published_config() -> BloomFilterBuilder {
+    BloomFilterBuilder::with_accuracy(CACHED_OBJECTS, 0.01)
+}
+
+/// Number of objects cached by the edge node.
+const CACHED_OBJECTS: u64 = 100_000;
+
+/// Object keys purged in this epoch: every 200th cached object, 500 in total.
+fn purged_objects() -> impl Iterator<Item = u64> {
+    (0..CACHED_OBJECTS).step_by(200)
+}
+
+fn main() {
+    // The edge node records every object it caches.
+    let mut cached = published_config().build().unwrap();
+    for object in 0..CACHED_OBJECTS {
+        cached.insert(object);
+    }
+
+    // The origin records every purged object using the same published 
configuration.
+    let mut purged = published_config().build().unwrap();
+    for object in purged_objects() {
+        purged.insert(object);
+    }
+
+    // Fold the purge list into the cache picture once, producing one artifact 
to store or
+    // ship instead of keeping both filters and querying both per lookup. A 
cached object
+    // survives only while none of its hash positions is occupied in the purge 
filter, so
+    // difference suits a subtracted set that is sparse in the shared shape — 
a purge list
+    // is naturally tiny next to a whole cache.
+    cached.difference(&purged).unwrap();
+
+    // The guarantee that matters for takedowns: a purged object is never 
served again.
+    for object in purged_objects() {
+        assert!(!cached.contains(&object));
+    }
+
+    // Count how many still-valid objects survived; dropped ones cost an 
origin refetch,
+    // never a stale serve.
+    let valid = CACHED_OBJECTS - purged_objects().count() as u64;
+    let mut retained = 0_u64;
+    for object in (0..CACHED_OBJECTS).filter(|object| object % 200 != 0) {
+        if cached.contains(&object) {
+            retained += 1;
+        }
+    }
+    println!("Cached objects:  {CACHED_OBJECTS}");
+    println!("Purged objects:  {}", purged_objects().count());
+    println!(
+        "Still served:    {retained} / {valid} valid objects ({:.1}% retained; 
drops become cache misses)",
+        retained as f64 / valid as f64 * 100.0
+    );
+    assert!(retained as f64 >= valid as f64 * 0.9);
+
+    // Ship the resulting filter to a sibling node; the purge filter is 
discarded.
+    let bytes = cached.serialize();
+    let shipped = BloomFilter::deserialize(&bytes).unwrap();
+    assert_eq!(shipped, cached);
+    println!("Shipped filter:  {} bytes", bytes.len());
+}
diff --git a/datasketches/src/bloom/mod.rs b/datasketches/src/bloom/mod.rs
index dabecdb..ff49e3f 100644
--- a/datasketches/src/bloom/mod.rs
+++ b/datasketches/src/bloom/mod.rs
@@ -28,8 +28,10 @@
 //! * **Fixed size**: Unlike typical sketches, Bloom filters do not resize 
automatically
 //! * **Linear space**: Size is proportional to the expected number of 
distinct items
 //!
-//! These guarantees describe normal operation. After 
[`invert()`](BloomFilter::invert) neither
-//! the no-false-negative nor the false-positive guarantee holds; see its 
documentation.
+//! Set operations transform the represented set: 
[`union()`](BloomFilter::union) keeps items
+//! from either filter, and [`intersect()`](BloomFilter::intersect) keeps only 
items in both.
+//! [`difference()`](BloomFilter::difference) excludes the right filter's 
items exactly, but
+//! may also drop left items whose hash positions collide with the right 
filter.
 //!
 //! # Usage
 //!
@@ -128,8 +130,10 @@
 //! // Intersect: recognizes only items in both filters
 //! // filter1.intersect(&filter2).unwrap();
 //!
-//! // Invert: approximately inverts set membership
-//! // filter1.invert();
+//! // Difference: recognizes items in filter1 that are definitely not in 
filter2
+//! filter1.difference(&filter2).unwrap();
+//! assert!(filter1.contains(&"a"));
+//! assert!(!filter1.contains(&"b"));
 //! ```
 //!
 //! # Implementation Details
diff --git a/datasketches/src/bloom/sketch.rs b/datasketches/src/bloom/sketch.rs
index d0d4b6b..fb311a8 100644
--- a/datasketches/src/bloom/sketch.rs
+++ b/datasketches/src/bloom/sketch.rs
@@ -38,8 +38,6 @@ const EMPTY_FLAG_MASK: u8 = 1 << 2;
 /// * No false negatives (inserted items always return `true`)
 /// * Tunable false positive rate
 /// * Constant space usage
-///
-/// These guarantees hold until [`invert()`](Self::invert) is called; see its 
documentation.
 #[derive(Debug, Clone, PartialEq)]
 pub struct BloomFilter {
     /// Hash seed for all hash functions
@@ -251,38 +249,70 @@ impl BloomFilter {
         Ok(())
     }
 
-    /// Inverts all bits in the filter.
+    /// Computes the approximate set difference with another filter via 
bitwise AND-NOT.
+    ///
+    /// After this operation, the filter approximates the set of items 
inserted into this
+    /// filter but not into `other`:
+    /// * Items inserted into `other` always return `false`: they are excluded 
exactly.
+    /// * Items inserted only into this filter keep returning `true` as long 
as none of their hash
+    ///   positions is occupied in `other`. Unlike [`union()`](Self::union) and
+    ///   [`intersect()`](Self::intersect), this operation can drop items, 
with a probability that
+    ///   grows with `other`'s load factor.
+    /// * Items never inserted into this filter may return `true` (false 
positives), at a rate no
+    ///   higher than this filter's false positive rate before the operation.
     ///
-    /// This approximately inverts the notion of set membership. After 
inversion, neither the
-    /// no-false-negative nor the false-positive guarantee holds: inserted 
items may return
-    /// `false` from [`contains()`](Self::contains), and 
[`is_empty()`](Self::is_empty),
-    /// [`bits_used()`](Self::bits_used), and 
[`load_factor()`](Self::load_factor) describe the
-    /// raw bit state rather than the inserted items.
+    /// This is the transformation other DataSketches libraries expose as A 
NOT B.
+    ///
+    /// # Errors
+    ///
+    /// Returns an error if the filters are not compatible (different size, 
number of hashes, or
+    /// seed). Use [`is_compatible()`](Self::is_compatible) to check first 
when an error is not
+    /// expected.
     ///
     /// # Examples
     ///
     /// ```
     /// use datasketches::bloom::BloomFilterBuilder;
     ///
-    /// let mut filter = BloomFilterBuilder::with_accuracy(100, 0.01)
+    /// let mut left = BloomFilterBuilder::with_accuracy(100, 0.01)
+    ///     .seed(123)
     ///     .build()
     ///     .unwrap();
-    /// filter.insert("apple");
+    /// let mut right = BloomFilterBuilder::with_accuracy(100, 0.01)
+    ///     .seed(123)
+    ///     .build()
+    ///     .unwrap();
+    ///
+    /// left.insert("apple");
+    /// left.insert("shared");
+    /// right.insert("grape");
+    /// right.insert("shared");
     ///
-    /// filter.invert();
-    /// // Now "apple" probably returns false, and most other items return true
+    /// left.difference(&right).unwrap();
+    /// assert!(left.contains(&"apple")); // Only in the left filter
+    /// assert!(!left.contains(&"shared")); // In both filters: excluded 
exactly
     /// ```
-    pub fn invert(&mut self) {
-        for word in &mut self.bit_array {
-            *word = !*word;
+    pub fn difference(&mut self, other: &BloomFilter) -> Result<(), Error> {
+        if !self.is_compatible(other) {
+            return Err(Error::invalid_argument(
+                "Bloom filters must have matching capacity, number of hashes, 
and seed",
+            ));
         }
-        self.num_bits_set = self.capacity() as u64 - self.num_bits_set;
+
+        // Count bits during difference operation (single pass)
+        let mut num_bits_set = 0;
+        for (word, other_word) in 
self.bit_array.iter_mut().zip(&other.bit_array) {
+            *word &= !*other_word;
+            num_bits_set += word.count_ones() as u64;
+        }
+        self.num_bits_set = num_bits_set;
+        Ok(())
     }
 
-    /// Returns whether no bits are set in the filter.
+    /// Returns `true` if no bits are set in the filter.
     ///
-    /// In normal operation this means no items were inserted. After 
[`invert()`](Self::invert),
-    /// it reports the raw bit state instead.
+    /// This is the case when no items were inserted, after 
[`reset()`](Self::reset), or when a
+    /// set operation cleared every bit.
     pub fn is_empty(&self) -> bool {
         self.num_bits_set == 0
     }
diff --git a/tests-integration/tests/bloom_test/sketch.rs 
b/tests-integration/tests/bloom_test/sketch.rs
index 3032fd9..db0a0db 100644
--- a/tests-integration/tests/bloom_test/sketch.rs
+++ b/tests-integration/tests/bloom_test/sketch.rs
@@ -15,6 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
+use datasketches::bloom::BloomFilter;
 use datasketches::bloom::BloomFilterBuilder;
 use datasketches::error::ErrorKind;
 use googletest::assert_that;
@@ -26,7 +27,7 @@ const NUM_BITS: u64 = 65_536;
 const NUM_HASHES: u16 = 5;
 const SEED: u64 = 123;
 
-fn filter() -> datasketches::bloom::BloomFilter {
+fn filter() -> BloomFilter {
     BloomFilterBuilder::with_size(NUM_BITS, NUM_HASHES)
         .seed(SEED)
         .build()
@@ -84,18 +85,79 @@ fn test_union_and_intersection() {
 }
 
 #[test]
-fn test_invert_is_reversible() {
-    let mut filter = filter();
-    filter.insert("apple");
-    filter.insert("banana");
+fn test_difference_excludes_right_items_exactly() {
+    let mut left = filter();
+    let mut right = filter();
+    for i in 0..100_u64 {
+        left.insert(i);
+        right.insert(i);
+    }
+    for i in 100..200_u64 {
+        left.insert(i);
+    }
+
+    let left_bits = left.bits_used();
+    left.difference(&right).unwrap();
+
+    // Items inserted into the right filter are excluded exactly.
+    for i in 0..100_u64 {
+        assert!(!left.contains(&i));
+    }
+    assert_that!(left.bits_used(), le(left_bits));
+
+    // The bit count must stay consistent with the backing array.
+    let restored = BloomFilter::deserialize(&left.serialize()).unwrap();
+    assert_eq!(restored, left);
+}
+
+#[test]
+fn test_difference_keeps_disjoint_items() {
+    let mut left = filter();
+    left.insert("shared");
+    left.insert("left");
+
+    let mut right = filter();
+    right.insert("shared");
+    right.insert("right");
+
+    left.difference(&right).unwrap();
+    assert!(left.contains(&"left"));
+    assert!(!left.contains(&"shared"));
+    assert!(!left.contains(&"right"));
+}
+
+#[test]
+fn test_difference_with_self_clears_filter() {
+    let mut left = filter();
+    left.insert("apple");
+    left.insert("banana");
+
+    let same = left.clone();
+    left.difference(&same).unwrap();
+    assert!(left.is_empty());
+    assert_eq!(left.bits_used(), 0);
+    assert!(!left.contains(&"apple"));
+}
 
-    let original = filter.clone();
-    let original_bits = filter.bits_used();
-    filter.invert();
-    assert_eq!(filter.bits_used(), filter.capacity() as u64 - original_bits);
+#[test]
+fn test_difference_with_empty_right_is_identity() {
+    let mut left = filter();
+    left.insert("apple");
 
-    filter.invert();
-    assert_eq!(filter, original);
+    let original = left.clone();
+    left.difference(&filter()).unwrap();
+    assert_eq!(left, original);
+}
+
+#[test]
+fn test_difference_rejects_incompatible_filters() {
+    let mut left = filter();
+    let right = BloomFilterBuilder::with_size(NUM_BITS, NUM_HASHES)
+        .seed(SEED + 1)
+        .build()
+        .unwrap();
+    let error = left.difference(&right).unwrap_err();
+    assert_eq!(error.kind(), ErrorKind::InvalidArgument);
 }
 
 #[test]


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

Reply via email to