docs: clarify BloomFilter contract after invert (#202)

Co-authored-by: tison <wander4096@gmail.com>
diff --git a/datasketches/src/bloom/builder.rs b/datasketches/src/bloom/builder.rs
deleted file mode 100644
index 6fece08..0000000
--- a/datasketches/src/bloom/builder.rs
+++ /dev/null
@@ -1,243 +0,0 @@
-// 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.
-
-use super::BloomFilter;
-use crate::codec::family::Family;
-use crate::hash::DEFAULT_UPDATE_SEED;
-
-/// Builder for creating [`BloomFilter`] instances.
-///
-/// Provides two construction modes:
-/// * [`with_accuracy()`](Self::with_accuracy): Specify target items and false positive rate
-///   (recommended)
-/// * [`with_size()`](Self::with_size): Specify requested bit count and hash functions (manual)
-#[derive(Debug, Clone)]
-pub struct BloomFilterBuilder {
-    num_bits: u64,
-    num_hashes: u16,
-    seed: u64,
-}
-
-impl BloomFilterBuilder {
-    /// Minimum allowed requested Bloom filter size, in bits.
-    pub const MIN_NUM_BITS: u64 = 1;
-    /// Maximum allowed requested Bloom filter size, in bits.
-    ///
-    /// Derived from serialization limits so the encoded sketch length fits in a signed 32-bit size
-    /// field.
-    pub const MAX_NUM_BITS: u64 = (i32::MAX as u64 - Family::BLOOMFILTER.max_pre_longs as u64) * 64;
-    /// Minimum allowed number of hash functions.
-    pub const MIN_NUM_HASHES: u16 = 1;
-    /// Maximum allowed number of hash functions.
-    pub const MAX_NUM_HASHES: u16 = i16::MAX as u16;
-
-    /// Creates a builder with optimal parameters for a target accuracy.
-    ///
-    /// Automatically calculates the optimal number of bits and hash functions
-    /// to achieve the desired false positive probability for a given number of items.
-    ///
-    /// # Arguments
-    ///
-    /// * `max_items`: Maximum expected number of distinct items.
-    /// * `fpp`: Target false positive probability (for example, `0.01` for `1%`).
-    ///
-    /// # Panics
-    ///
-    /// Panics if `max_items` is `0` or `fpp` is outside `(0.0, 1.0]`.
-    ///
-    /// # Examples
-    ///
-    /// ```
-    /// use datasketches::bloom::BloomFilterBuilder;
-    ///
-    /// // Optimal for 10,000 items with 1% FPP
-    /// let filter = BloomFilterBuilder::with_accuracy(10_000, 0.01)
-    ///     .seed(42)
-    ///     .build();
-    /// ```
-    pub fn with_accuracy(max_items: u64, fpp: f64) -> Self {
-        assert!(max_items > 0, "max_items must be greater than 0");
-        assert!(
-            fpp > 0.0 && fpp <= 1.0,
-            "fpp must be between 0.0 and 1.0 (inclusive of 1.0)"
-        );
-
-        let num_bits = Self::suggest_num_bits(max_items, fpp);
-        let num_hashes = Self::suggest_num_hashes_from_accuracy(max_items, num_bits);
-
-        BloomFilterBuilder {
-            num_bits,
-            num_hashes,
-            seed: DEFAULT_UPDATE_SEED,
-        }
-    }
-
-    /// Creates a builder with manual size specification.
-    ///
-    /// Use this when you want precise control over the requested filter size,
-    /// or when working with pre-calculated parameters.
-    ///
-    /// The underlying storage is word-based, so the actual capacity is rounded
-    /// up to the next multiple of 64 bits.
-    ///
-    /// # Arguments
-    ///
-    /// * `num_bits`: Total number of bits in the filter.
-    /// * `num_hashes`: Number of hash functions to use.
-    ///
-    /// # Panics
-    ///
-    /// Panics if any of:
-    /// * `num_bits < Self::MIN_NUM_BITS` or `num_bits > Self::MAX_NUM_BITS`.
-    /// * `num_hashes < Self::MIN_NUM_HASHES` or `num_hashes > Self::MAX_NUM_HASHES`.
-    ///
-    /// # Examples
-    ///
-    /// ```
-    /// use datasketches::bloom::BloomFilterBuilder;
-    ///
-    /// let filter = BloomFilterBuilder::with_size(10_000, 7).build();
-    /// ```
-    pub fn with_size(num_bits: u64, num_hashes: u16) -> Self {
-        assert!(
-            (Self::MIN_NUM_BITS..=Self::MAX_NUM_BITS).contains(&num_bits),
-            "num_bits must be between {} and {}, got {}",
-            Self::MIN_NUM_BITS,
-            Self::MAX_NUM_BITS,
-            num_bits,
-        );
-        assert!(
-            (Self::MIN_NUM_HASHES..=Self::MAX_NUM_HASHES).contains(&num_hashes),
-            "num_hashes must be between {} and {}, got {}",
-            Self::MIN_NUM_HASHES,
-            Self::MAX_NUM_HASHES,
-            num_hashes
-        );
-
-        BloomFilterBuilder {
-            num_bits,
-            num_hashes,
-            seed: DEFAULT_UPDATE_SEED,
-        }
-    }
-
-    /// Sets a custom hash seed (default: 9001).
-    ///
-    /// **Important**: Filters with different seeds cannot be merged.
-    ///
-    /// # Examples
-    ///
-    /// ```
-    /// use datasketches::bloom::BloomFilterBuilder;
-    ///
-    /// let filter = BloomFilterBuilder::with_accuracy(100, 0.01)
-    ///     .seed(12345)
-    ///     .build();
-    /// ```
-    pub fn seed(mut self, seed: u64) -> Self {
-        self.seed = seed;
-        self
-    }
-
-    /// Builds the Bloom filter.
-    ///
-    /// # Panics
-    ///
-    /// Panics if neither `with_accuracy()` nor `with_size()` was called.
-    pub fn build(self) -> BloomFilter {
-        let num_hashes = self.num_hashes;
-        let num_words = self.num_bits.div_ceil(64) as usize;
-        let bit_array = vec![0u64; num_words].into_boxed_slice();
-
-        BloomFilter {
-            seed: self.seed,
-            num_hashes,
-            num_bits_set: 0,
-            bit_array,
-        }
-    }
-
-    /// Suggests optimal number of bits given max items and target FPP.
-    ///
-    /// Formula: `m = -n * ln(p) / (ln(2)^2)`
-    /// where n = max_items, p = fpp
-    ///
-    /// # Examples
-    ///
-    /// ```
-    /// use datasketches::bloom::BloomFilterBuilder;
-    ///
-    /// let bits = BloomFilterBuilder::suggest_num_bits(1000, 0.01);
-    /// assert!(bits > 9000 && bits < 10000); // ~9585 bits
-    /// ```
-    pub fn suggest_num_bits(max_items: u64, fpp: f64) -> u64 {
-        let n = max_items as f64;
-        let p = fpp;
-        let ln2_squared = std::f64::consts::LN_2 * std::f64::consts::LN_2;
-
-        let bits = (-n * p.ln() / ln2_squared).ceil() as u64;
-
-        bits.clamp(Self::MIN_NUM_BITS, Self::MAX_NUM_BITS)
-    }
-
-    /// Suggests optimal number of hash functions given max items and bit count.
-    ///
-    /// Formula: `k = (m/n) * ln(2)`
-    /// where m = num_bits, n = max_items
-    ///
-    /// # Examples
-    ///
-    /// ```
-    /// use datasketches::bloom::BloomFilterBuilder;
-    ///
-    /// let hashes = BloomFilterBuilder::suggest_num_hashes_from_accuracy(1000, 10000);
-    /// assert_eq!(hashes, 7); // Optimal k ≈ 6.93
-    /// ```
-    pub fn suggest_num_hashes_from_accuracy(max_items: u64, num_bits: u64) -> u16 {
-        let m = num_bits as f64;
-        let n = max_items as f64;
-
-        // Ceil to avoid selecting too few hashes.
-        let k = (m / n * std::f64::consts::LN_2).ceil();
-        k.clamp(
-            f64::from(Self::MIN_NUM_HASHES),
-            f64::from(Self::MAX_NUM_HASHES),
-        ) as u16
-    }
-
-    /// Suggests optimal number of hash functions from target FPP.
-    ///
-    /// Formula: `k = -log2(p)`
-    /// where p = fpp
-    ///
-    /// # Examples
-    ///
-    /// ```
-    /// use datasketches::bloom::BloomFilterBuilder;
-    ///
-    /// let hashes = BloomFilterBuilder::suggest_num_hashes_from_fpp(0.01);
-    /// assert_eq!(hashes, 7); // -log2(0.01) ≈ 6.64
-    /// ```
-    pub fn suggest_num_hashes_from_fpp(fpp: f64) -> u16 {
-        // Ceil to avoid selecting too few hashes.
-        let k = -fpp.log2();
-        k.ceil().clamp(
-            f64::from(Self::MIN_NUM_HASHES),
-            f64::from(Self::MAX_NUM_HASHES),
-        ) as u16
-    }
-}
diff --git a/datasketches/src/bloom/mod.rs b/datasketches/src/bloom/mod.rs
index b6f6437..4ffa0f8 100644
--- a/datasketches/src/bloom/mod.rs
+++ b/datasketches/src/bloom/mod.rs
@@ -28,6 +28,9 @@
 //! * **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.
+//!
 //! # Usage
 //!
 //! ```
@@ -123,8 +126,7 @@
 //! * Kirsch and Mitzenmacher (2008). "Less Hashing, Same Performance: Building a Better Bloom
 //!   Filter"
 
-mod builder;
 mod sketch;
 
-pub use self::builder::BloomFilterBuilder;
 pub use self::sketch::BloomFilter;
+pub use self::sketch::BloomFilterBuilder;
diff --git a/datasketches/src/bloom/sketch.rs b/datasketches/src/bloom/sketch.rs
index e8734e7..6eb3a45 100644
--- a/datasketches/src/bloom/sketch.rs
+++ b/datasketches/src/bloom/sketch.rs
@@ -25,6 +25,7 @@
 use crate::codec::assert::insufficient_data;
 use crate::codec::family::Family;
 use crate::error::Error;
+use crate::hash::DEFAULT_UPDATE_SEED;
 use crate::hash::XxHash64;
 
 // Serialization constants
@@ -38,17 +39,17 @@
 /// * Tunable false positive rate
 /// * Constant space usage
 ///
-/// Use [`super::BloomFilterBuilder`] to construct instances.
+/// 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
-    pub(super) seed: u64,
+    seed: u64,
     /// Number of hash functions to use (k)
-    pub(super) num_hashes: u16,
+    num_hashes: u16,
     /// Count of bits set to 1 (for statistics)
-    pub(super) num_bits_set: u64,
+    num_bits_set: u64,
     /// Bit array packed into u64 words
-    pub(super) bit_array: Box<[u64]>,
+    bit_array: Box<[u64]>,
 }
 
 impl BloomFilter {
@@ -236,8 +237,11 @@
 
     /// Inverts all bits in the filter.
     ///
-    /// This approximately inverts the notion of set membership, though the false
-    /// positive guarantees no longer hold in a well-defined way.
+    /// 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.
     ///
     /// # Examples
     ///
@@ -257,7 +261,10 @@
         self.num_bits_set = self.capacity() as u64 - self.num_bits_set;
     }
 
-    /// Returns whether the filter is empty (no items inserted).
+    /// Returns whether 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.
     pub fn is_empty(&self) -> bool {
         self.num_bits_set == 0
     }
@@ -477,9 +484,8 @@
                 counted_bits_set += word.count_ones() as u64;
             }
 
-            // Handle "dirty" state: 0xFFFFFFFFFFFFFFFF indicates bits need recounting.
-            const DIRTY_BITS_VALUE: u64 = 0xFFFFFFFFFFFFFFFF;
-            if raw_num_bits_set == DIRTY_BITS_VALUE {
+            // Handle "dirty" state: u64::MAX (all bits set to 1) indicates bits need recounting.
+            if raw_num_bits_set == u64::MAX {
                 num_bits_set = counted_bits_set;
             } else {
                 if raw_num_bits_set != counted_bits_set {
@@ -544,7 +550,7 @@
     /// hash_index = ((h0 + i * h1) >> 1) % capacity_bits
     /// ```
     ///
-    /// The right shift by 1 improves bit distribution. The index `i` is 1-based.
+    /// The right shift by 1 improves bit-distribution. The index `i` is 1-based.
     fn compute_bit_index(&self, h0: u64, h1: u64, i: u16) -> usize {
         let hash = h0.wrapping_add(u64::from(i).wrapping_mul(h1)) as usize;
         (hash >> 1) % self.capacity()
@@ -575,3 +581,222 @@
         size_of::<Self>() + self.bit_array.len() * size_of::<u64>()
     }
 }
+
+/// Builder for creating [`BloomFilter`] instances.
+///
+/// Provides two construction modes:
+/// * [`with_accuracy()`](Self::with_accuracy): Specify target items and false positive rate
+///   (recommended)
+/// * [`with_size()`](Self::with_size): Specify requested bit count and hash functions (manual)
+#[derive(Debug, Clone)]
+pub struct BloomFilterBuilder {
+    num_bits: u64,
+    num_hashes: u16,
+    seed: u64,
+}
+
+impl BloomFilterBuilder {
+    /// Minimum allowed requested Bloom filter size, in bits.
+    pub const MIN_NUM_BITS: u64 = 1;
+    /// Maximum allowed requested Bloom filter size, in bits.
+    ///
+    /// Derived from serialization limits so the encoded sketch length fits in a signed 32-bit size
+    /// field.
+    pub const MAX_NUM_BITS: u64 = (i32::MAX as u64 - Family::BLOOMFILTER.max_pre_longs as u64) * 64;
+    /// Minimum allowed number of hash functions.
+    pub const MIN_NUM_HASHES: u16 = 1;
+    /// Maximum allowed number of hash functions.
+    pub const MAX_NUM_HASHES: u16 = i16::MAX as u16;
+
+    /// Creates a builder with optimal parameters for a target accuracy.
+    ///
+    /// Automatically calculates the optimal number of bits and hash functions
+    /// to achieve the desired false positive probability for a given number of items.
+    ///
+    /// # Arguments
+    ///
+    /// * `max_items`: Maximum expected number of distinct items.
+    /// * `fpp`: Target false positive probability (for example, `0.01` for `1%`).
+    ///
+    /// # Panics
+    ///
+    /// Panics if `max_items` is `0` or `fpp` is outside `(0.0, 1.0]`.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use datasketches::bloom::BloomFilterBuilder;
+    ///
+    /// // Optimal for 10,000 items with 1% FPP
+    /// let filter = BloomFilterBuilder::with_accuracy(10_000, 0.01)
+    ///     .seed(42)
+    ///     .build();
+    /// ```
+    pub fn with_accuracy(max_items: u64, fpp: f64) -> Self {
+        assert!(max_items > 0, "max_items must be greater than 0");
+        assert!(
+            fpp > 0.0 && fpp <= 1.0,
+            "fpp must be between 0.0 and 1.0 (inclusive of 1.0)"
+        );
+
+        let num_bits = Self::suggest_num_bits(max_items, fpp);
+        let num_hashes = Self::suggest_num_hashes_from_accuracy(max_items, num_bits);
+
+        BloomFilterBuilder {
+            num_bits,
+            num_hashes,
+            seed: DEFAULT_UPDATE_SEED,
+        }
+    }
+
+    /// Creates a builder with manual size specification.
+    ///
+    /// Use this when you want precise control over the requested filter size,
+    /// or when working with pre-calculated parameters.
+    ///
+    /// The underlying storage is word-based, so the actual capacity is rounded
+    /// up to the next multiple of 64 bits.
+    ///
+    /// # Arguments
+    ///
+    /// * `num_bits`: Total number of bits in the filter.
+    /// * `num_hashes`: Number of hash functions to use.
+    ///
+    /// # Panics
+    ///
+    /// Panics if any of:
+    /// * `num_bits < Self::MIN_NUM_BITS` or `num_bits > Self::MAX_NUM_BITS`.
+    /// * `num_hashes < Self::MIN_NUM_HASHES` or `num_hashes > Self::MAX_NUM_HASHES`.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use datasketches::bloom::BloomFilterBuilder;
+    ///
+    /// let filter = BloomFilterBuilder::with_size(10_000, 7).build();
+    /// ```
+    pub fn with_size(num_bits: u64, num_hashes: u16) -> Self {
+        assert!(
+            (Self::MIN_NUM_BITS..=Self::MAX_NUM_BITS).contains(&num_bits),
+            "num_bits must be between {} and {}, got {}",
+            Self::MIN_NUM_BITS,
+            Self::MAX_NUM_BITS,
+            num_bits,
+        );
+        assert!(
+            (Self::MIN_NUM_HASHES..=Self::MAX_NUM_HASHES).contains(&num_hashes),
+            "num_hashes must be between {} and {}, got {}",
+            Self::MIN_NUM_HASHES,
+            Self::MAX_NUM_HASHES,
+            num_hashes
+        );
+
+        BloomFilterBuilder {
+            num_bits,
+            num_hashes,
+            seed: DEFAULT_UPDATE_SEED,
+        }
+    }
+
+    /// Sets a custom hash seed (default: 9001).
+    ///
+    /// **Important**: Filters with different seeds cannot be merged.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use datasketches::bloom::BloomFilterBuilder;
+    ///
+    /// let filter = BloomFilterBuilder::with_accuracy(100, 0.01)
+    ///     .seed(12345)
+    ///     .build();
+    /// ```
+    pub fn seed(mut self, seed: u64) -> Self {
+        self.seed = seed;
+        self
+    }
+
+    /// Builds the Bloom filter.
+    pub fn build(self) -> BloomFilter {
+        let num_hashes = self.num_hashes;
+        let num_words = self.num_bits.div_ceil(64) as usize;
+        let bit_array = vec![0u64; num_words].into_boxed_slice();
+
+        BloomFilter {
+            seed: self.seed,
+            num_hashes,
+            num_bits_set: 0,
+            bit_array,
+        }
+    }
+
+    /// Suggests optimal number of bits given max items and target FPP.
+    ///
+    /// Formula: `m = -n * ln(p) / (ln(2)^2)`
+    /// where n = max_items, p = fpp
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use datasketches::bloom::BloomFilterBuilder;
+    ///
+    /// let bits = BloomFilterBuilder::suggest_num_bits(1000, 0.01);
+    /// assert!(bits > 9000 && bits < 10000); // ~9585 bits
+    /// ```
+    pub fn suggest_num_bits(max_items: u64, fpp: f64) -> u64 {
+        let n = max_items as f64;
+        let p = fpp;
+        let ln2_squared = std::f64::consts::LN_2 * std::f64::consts::LN_2;
+
+        let bits = (-n * p.ln() / ln2_squared).ceil() as u64;
+
+        bits.clamp(Self::MIN_NUM_BITS, Self::MAX_NUM_BITS)
+    }
+
+    /// Suggests optimal number of hash functions given max items and bit count.
+    ///
+    /// Formula: `k = (m/n) * ln(2)`
+    /// where m = num_bits, n = max_items
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use datasketches::bloom::BloomFilterBuilder;
+    ///
+    /// let hashes = BloomFilterBuilder::suggest_num_hashes_from_accuracy(1000, 10000);
+    /// assert_eq!(hashes, 7); // Optimal k ≈ 6.93
+    /// ```
+    pub fn suggest_num_hashes_from_accuracy(max_items: u64, num_bits: u64) -> u16 {
+        let m = num_bits as f64;
+        let n = max_items as f64;
+
+        // Ceil to avoid selecting too few hashes.
+        let k = (m / n * std::f64::consts::LN_2).ceil();
+        k.clamp(
+            f64::from(Self::MIN_NUM_HASHES),
+            f64::from(Self::MAX_NUM_HASHES),
+        ) as u16
+    }
+
+    /// Suggests optimal number of hash functions from target FPP.
+    ///
+    /// Formula: `k = -log2(p)`
+    /// where p = fpp
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use datasketches::bloom::BloomFilterBuilder;
+    ///
+    /// let hashes = BloomFilterBuilder::suggest_num_hashes_from_fpp(0.01);
+    /// assert_eq!(hashes, 7); // -log2(0.01) ≈ 6.64
+    /// ```
+    pub fn suggest_num_hashes_from_fpp(fpp: f64) -> u16 {
+        // Ceil to avoid selecting too few hashes.
+        let k = -fpp.log2();
+        k.ceil().clamp(
+            f64::from(Self::MIN_NUM_HASHES),
+            f64::from(Self::MAX_NUM_HASHES),
+        ) as u16
+    }
+}