diff --git a/CHANGELOG.md b/CHANGELOG.md index ad6104c..65a3f65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All significant changes to this project will be documented in this file. ## Unreleased +### New features + +* Add immutable xor filters behind the `xor` feature, with 8- and 16-bit fingerprints, pre-hashed input APIs, and compatible serialization. + ## v0.4.0 (2026-08-18) ### Breaking changes diff --git a/README.md b/README.md index f8d1593..a245df0 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ Enable multiple algorithms by listing their features together, such as `features | `tdigest` | `TDigestMut`, `TDigest` | Quantile and rank estimation, with high accuracy near distribution tails. | | `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. | +| `xor` | `XorFilter`, `XorFilterBuilder` | Compact immutable probabilistic set membership with 8- or 16-bit fingerprints. | See the [API documentation](https://docs.rs/datasketches) for configuration, accuracy guarantees, serialization, and examples for each algorithm. diff --git a/datasketches/Cargo.toml b/datasketches/Cargo.toml index 169ee7e..db22578 100644 --- a/datasketches/Cargo.toml +++ b/datasketches/Cargo.toml @@ -46,6 +46,7 @@ hll = [] tdigest = [] theta = [] tuple = [] +xor = [] [[test]] name = "bloom_test" @@ -87,6 +88,11 @@ name = "tuple_test" path = "tests/tuple_test/main.rs" required-features = ["tuple"] +[[test]] +name = "xor_test" +path = "tests/xor_test/main.rs" +required-features = ["xor"] + [dev-dependencies] googletest = { workspace = true } insta = { workspace = true } diff --git a/datasketches/src/codec/family.rs b/datasketches/src/codec/family.rs index c6ab908..98207ca 100644 --- a/datasketches/src/codec/family.rs +++ b/datasketches/src/codec/family.rs @@ -106,6 +106,15 @@ impl Family { min_pre_longs: 3, max_pre_longs: 4, }; + + /// Xor filter for probabilistic set membership. + #[cfg(feature = "xor")] + pub const XORFILTER: Family = Family { + id: 22, + name: "XORFILTER", + min_pre_longs: 3, + max_pre_longs: 3, + }; } impl Family { diff --git a/datasketches/src/codec/mod.rs b/datasketches/src/codec/mod.rs index 30d1075..1cd4cfc 100644 --- a/datasketches/src/codec/mod.rs +++ b/datasketches/src/codec/mod.rs @@ -31,6 +31,7 @@ pub use self::encode::SketchBytes; feature = "tdigest", feature = "theta", feature = "tuple", + feature = "xor", ))] #[allow(dead_code)] // some utilities are only used for certain sketches pub(crate) mod assert; @@ -44,5 +45,6 @@ pub(crate) mod assert; feature = "tdigest", feature = "theta", feature = "tuple", + feature = "xor", ))] pub(crate) mod family; diff --git a/datasketches/src/hash/mod.rs b/datasketches/src/hash/mod.rs index c5c26c4..04cd807 100644 --- a/datasketches/src/hash/mod.rs +++ b/datasketches/src/hash/mod.rs @@ -38,9 +38,9 @@ mod murmurhash; ))] pub(crate) use self::murmurhash::*; -#[cfg(feature = "bloom")] +#[cfg(any(feature = "bloom", feature = "xor"))] mod xxhash; -#[cfg(feature = "bloom")] +#[cfg(any(feature = "bloom", feature = "xor"))] pub(crate) use self::xxhash::*; #[cfg(any( @@ -95,6 +95,7 @@ pub(crate) const DEFAULT_UPDATE_SEED: u64 = 9001; feature = "hll", feature = "theta", feature = "tuple", + feature = "xor", ))] fn read_u64_le(bytes: &[u8]) -> u64 { let mut buf = [0u8; 8]; diff --git a/datasketches/src/lib.rs b/datasketches/src/lib.rs index e169c4c..6013b29 100644 --- a/datasketches/src/lib.rs +++ b/datasketches/src/lib.rs @@ -51,6 +51,8 @@ pub use self::thetafamily::common as thetacommon; pub use self::thetafamily::theta; #[cfg(feature = "tuple")] pub use self::thetafamily::tuple; +#[cfg(feature = "xor")] +pub mod xor; // common modules pub mod codec; diff --git a/datasketches/src/xor/filter.rs b/datasketches/src/xor/filter.rs new file mode 100644 index 0000000..6fd7997 --- /dev/null +++ b/datasketches/src/xor/filter.rs @@ -0,0 +1,535 @@ +// 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 std::hash::Hash; +use std::hash::Hasher; +use std::ops::BitXor; + +use crate::error::Error; +use crate::hash::XxHash64; + +const NUM_HASHES: u8 = 3; +const HASH_SEED: u64 = 0; +const DEFAULT_CONSTRUCTION_SEED: u64 = 0; +const LOAD_FACTOR: f64 = 1.23; +const CAPACITY_OFFSET: u64 = 32; +const MAX_CONSTRUCTION_ATTEMPTS: usize = 100; + +const MURMUR_C1: u64 = 0xff51_afd7_ed55_8ccd; +const MURMUR_C2: u64 = 0xc4ce_b9fe_1a85_ec53; + +const SPLITMIX_GAMMA: u64 = 0x9e37_79b9_7f4a_7c15; +const SPLITMIX_MUL1: u64 = 0xbf58_476d_1ce4_e5b9; +const SPLITMIX_MUL2: u64 = 0x94d0_49bb_1331_11eb; + +/// Fingerprint representation used by an [`XorFilter`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum XorFilterType { + /// Uses 8-bit fingerprints and has an expected false-positive probability of about `1 / 256`. + Xor8, + /// Uses 16-bit fingerprints and has an expected false-positive probability of about + /// `1 / 65_536`. + Xor16, +} + +impl XorFilterType { + /// Returns the number of bits in each fingerprint. + pub const fn bits_per_fingerprint(self) -> u8 { + match self { + Self::Xor8 => 8, + Self::Xor16 => 16, + } + } + + pub(super) const fn bytes_per_fingerprint(self) -> usize { + (self.bits_per_fingerprint() / 8) as usize + } + + pub(super) fn from_bits(bits: u8) -> Result { + match bits { + 8 => Ok(Self::Xor8), + 16 => Ok(Self::Xor16), + _ => Err(Error::deserial(format!( + "invalid fingerprint width: expected 8 or 16, got {bits}" + ))), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum Fingerprints { + Xor8(Box<[u8]>), + Xor16(Box<[u16]>), +} + +impl Fingerprints { + pub(super) fn len(&self) -> usize { + match self { + Self::Xor8(values) => values.len(), + Self::Xor16(values) => values.len(), + } + } + + pub(super) fn byte_len(&self) -> usize { + match self { + Self::Xor8(values) => size_of_val::<[u8]>(values), + Self::Xor16(values) => size_of_val::<[u16]>(values), + } + } +} + +/// An immutable xor filter for probabilistic set membership. +/// +/// A query that returns `false` proves that the item was not in the input set. A query that returns +/// `true` may be a false positive, with probability determined by [`XorFilterType`]. Values cannot +/// be added after construction; rebuild the filter when the set changes. +/// +/// Ordinary values passed to [`contains`](Self::contains) are reduced to 64-bit hashes with +/// xxHash64 and seed `0`. Use [`contains_hash`](Self::contains_hash) only with the same hashes that +/// were supplied to [`XorFilter::from_hashes`] or [`XorFilterBuilder::update_hash`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct XorFilter { + pub(super) filter_type: XorFilterType, + pub(super) segment_length: usize, + pub(super) num_items: usize, + pub(super) seed: u64, + pub(super) fingerprints: Fingerprints, +} + +impl XorFilter { + /// Builds a filter from precomputed 64-bit hashes. + /// + /// Duplicate hashes are removed before construction. The same hash values must be passed to + /// [`contains_hash`](Self::contains_hash) when querying the result. + /// + /// # Errors + /// + /// Returns an error if the input is too large for the portable serialization format or if no + /// peelable construction is found within the bounded retry limit. + /// + /// # Examples + /// + /// ``` + /// use datasketches::xor::XorFilter; + /// use datasketches::xor::XorFilterType; + /// + /// let filter = XorFilter::from_hashes([10, 20, 30], XorFilterType::Xor8).unwrap(); + /// assert!(filter.contains_hash(20)); + /// ``` + pub fn from_hashes( + hashes: impl IntoIterator, + filter_type: XorFilterType, + ) -> Result { + let mut builder = XorFilterBuilder::new(filter_type); + builder.extend_hashes(hashes); + builder.build() + } + + /// Returns `true` if an item is possibly in the input set. + /// + /// A `false` result means the item was definitely absent; a `true` result may be a false + /// positive. + #[inline] + pub fn contains(&self, item: &T) -> bool { + self.contains_hash(hash_item(item)) + } + + /// Returns `true` if a precomputed 64-bit hash is possibly in the input set. + /// + /// This method bypasses xxHash64. The caller must use the same hash value during construction + /// and queries; mixing raw values with precomputed hashes can introduce false negatives. + #[inline] + pub fn contains_hash(&self, hash: u64) -> bool { + let mixed = mix(hash, self.seed); + let [h0, h1, h2] = indexes(mixed, self.segment_length); + + match &self.fingerprints { + Fingerprints::Xor8(fingerprints) => { + fingerprint(mixed) as u8 == fingerprints[h0] ^ fingerprints[h1] ^ fingerprints[h2] + } + Fingerprints::Xor16(fingerprints) => { + fingerprint(mixed) == fingerprints[h0] ^ fingerprints[h1] ^ fingerprints[h2] + } + } + } + + /// Returns `true` if no distinct hashes were used to build the filter. + pub fn is_empty(&self) -> bool { + self.num_items == 0 + } + + /// Returns the number of distinct 64-bit hashes used to build the filter. + pub fn num_items(&self) -> usize { + self.num_items + } + + /// Returns the fingerprint representation. + pub fn filter_type(&self) -> XorFilterType { + self.filter_type + } + + /// Returns the number of bits in each fingerprint. + pub fn bits_per_fingerprint(&self) -> u8 { + self.filter_type.bits_per_fingerprint() + } + + /// Returns the number of hash locations read by every query. + pub fn num_hashes(&self) -> u8 { + NUM_HASHES + } + + /// Returns the number of fingerprint slots in the filter. + pub fn capacity(&self) -> usize { + self.fingerprints.len() + } + + /// Returns the construction seed stored with the filter. + pub fn seed(&self) -> u64 { + self.seed + } + + /// Returns the number of fingerprint bits allocated per distinct input hash. + /// + /// Returns `0.0` for an empty filter. For sufficiently large inputs the value approaches `9.84` + /// for [`XorFilterType::Xor8`] and `19.68` for [`XorFilterType::Xor16`]. + pub fn bits_per_item(&self) -> f64 { + if self.is_empty() { + return 0.0; + } + self.capacity() as f64 * f64::from(self.bits_per_fingerprint()) / self.num_items as f64 + } + + /// Returns the estimated in-memory size of the filter in bytes. + pub fn estimated_size(&self) -> usize { + size_of::() + self.fingerprints.byte_len() + } + + fn build(keys: &[u64], filter_type: XorFilterType, base_seed: u64) -> Result { + let num_items = keys.len(); + let capacity = compute_capacity(num_items)?; + let payload_bytes = capacity + .checked_mul(filter_type.bytes_per_fingerprint()) + .ok_or_else(|| Error::invalid_argument("xor filter fingerprint size overflow"))?; + if payload_bytes > i32::MAX as usize { + return Err(Error::invalid_argument(format!( + "xor filter requires {payload_bytes} fingerprint bytes, exceeding the portable limit of {}", + i32::MAX + ))); + } + + let segment_length = capacity / usize::from(NUM_HASHES); + let mut xor_mask = vec![0_u64; capacity]; + let mut count = vec![0_u32; capacity]; + let mut queue = vec![0_u32; capacity]; + let mut stack_hash = vec![0_u64; num_items]; + let mut stack_index = vec![0_u32; num_items]; + + let mut rng_state = base_seed; + let mut construction_seed = 0; + let mut stack_size = 0; + for _ in 0..MAX_CONSTRUCTION_ATTEMPTS { + rng_state = rng_state.wrapping_add(SPLITMIX_GAMMA); + construction_seed = splitmix64(rng_state); + stack_size = map( + keys, + construction_seed, + segment_length, + &mut xor_mask, + &mut count, + &mut queue, + &mut stack_hash, + &mut stack_index, + ); + if stack_size == num_items { + break; + } + } + + if stack_size != num_items { + return Err(Error::invalid_argument(format!( + "xor filter construction failed after {MAX_CONSTRUCTION_ATTEMPTS} attempts" + )) + .with_context("num_items", num_items)); + } + + let fingerprints = match filter_type { + XorFilterType::Xor8 => { + let mut values = vec![0_u8; capacity].into_boxed_slice(); + assign_fingerprints( + &mut values, + segment_length, + &stack_hash[..stack_size], + &stack_index[..stack_size], + |hash| fingerprint(hash) as u8, + ); + Fingerprints::Xor8(values) + } + XorFilterType::Xor16 => { + let mut values = vec![0_u16; capacity].into_boxed_slice(); + assign_fingerprints( + &mut values, + segment_length, + &stack_hash[..stack_size], + &stack_index[..stack_size], + fingerprint, + ); + Fingerprints::Xor16(values) + } + }; + + Ok(Self { + filter_type, + segment_length, + num_items, + seed: construction_seed, + fingerprints, + }) + } +} + +/// Builder for accumulating values and creating an immutable [`XorFilter`]. +/// +/// Values are reduced to 64-bit hashes as they are added, so the builder retains one `u64` per +/// update regardless of the original value size. Duplicate hashes are removed by +/// [`build`](Self::build). +#[derive(Debug, Clone)] +pub struct XorFilterBuilder { + filter_type: XorFilterType, + seed: u64, + hashes: Vec, +} + +impl XorFilterBuilder { + /// Creates a builder for the requested fingerprint representation. + /// + /// The default base construction seed is `0`. + pub fn new(filter_type: XorFilterType) -> Self { + Self { + filter_type, + seed: DEFAULT_CONSTRUCTION_SEED, + hashes: Vec::new(), + } + } + + /// Sets the base seed used to derive construction attempts. + /// + /// A fixed seed makes construction deterministic for a given set of hashes. The filter stores + /// the successful derived seed, which is returned by [`XorFilter::seed`]. + pub fn seed(mut self, seed: u64) -> Self { + self.seed = seed; + self + } + + /// Updates the builder with a value hashed by xxHash64 with seed `0`. + pub fn update(&mut self, item: T) { + self.hashes.push(hash_item(&item)); + } + + /// Updates the builder with a precomputed 64-bit hash. + /// + /// This method bypasses xxHash64. Query the resulting filter with + /// [`XorFilter::contains_hash`] using hashes from the same source. + pub fn update_hash(&mut self, hash: u64) { + self.hashes.push(hash); + } + + /// Extends the builder with values hashed by xxHash64 with seed `0`. + pub fn extend(&mut self, items: impl IntoIterator) { + self.hashes + .extend(items.into_iter().map(|item| hash_item(&item))); + } + + /// Extends the builder with precomputed 64-bit hashes. + pub fn extend_hashes(&mut self, hashes: impl IntoIterator) { + self.hashes.extend(hashes); + } + + /// Returns the number of updates accumulated so far, including duplicates. + pub fn num_items(&self) -> usize { + self.hashes.len() + } + + /// Returns `true` if no updates have been accumulated. + pub fn is_empty(&self) -> bool { + self.hashes.is_empty() + } + + /// Builds an immutable filter after removing duplicate hashes. + /// + /// # Errors + /// + /// Returns an error if the input is too large for the portable serialization format or if no + /// peelable construction is found within the bounded retry limit. + pub fn build(mut self) -> Result { + self.hashes.sort_unstable(); + self.hashes.dedup(); + XorFilter::build(&self.hashes, self.filter_type, self.seed) + } +} + +fn hash_item(item: &T) -> u64 { + let mut hasher = XxHash64::with_seed(HASH_SEED); + item.hash(&mut hasher); + hasher.finish() +} + +fn compute_capacity(num_items: usize) -> Result { + if num_items > i32::MAX as usize { + return Err(Error::invalid_argument(format!( + "xor filter item count exceeds portable limit: {num_items}" + ))); + } + + let scaled = (LOAD_FACTOR * num_items as f64) as u64; + let capacity = CAPACITY_OFFSET + .checked_add(scaled) + .ok_or_else(|| Error::invalid_argument("xor filter capacity overflow"))?; + let capacity = capacity / u64::from(NUM_HASHES) * u64::from(NUM_HASHES); + let capacity = capacity.max(u64::from(NUM_HASHES)); + if capacity > i32::MAX as u64 { + return Err(Error::invalid_argument(format!( + "xor filter capacity exceeds portable limit: {capacity}" + ))); + } + Ok(capacity as usize) +} + +fn map( + keys: &[u64], + seed: u64, + segment_length: usize, + xor_mask: &mut [u64], + count: &mut [u32], + queue: &mut [u32], + stack_hash: &mut [u64], + stack_index: &mut [u32], +) -> usize { + xor_mask.fill(0); + count.fill(0); + + for &key in keys { + let hash = mix(key, seed); + for index in indexes(hash, segment_length) { + xor_mask[index] ^= hash; + count[index] += 1; + } + } + + let mut queue_length = 0; + for (index, &value) in count.iter().enumerate() { + if value == 1 { + queue[queue_length] = index as u32; + queue_length += 1; + } + } + + let mut stack_size = 0; + while queue_length > 0 { + queue_length -= 1; + let index = queue[queue_length] as usize; + if count[index] != 1 { + continue; + } + + let hash = xor_mask[index]; + stack_hash[stack_size] = hash; + stack_index[stack_size] = index as u32; + stack_size += 1; + + for hash_index in indexes(hash, segment_length) { + count[hash_index] -= 1; + xor_mask[hash_index] ^= hash; + if count[hash_index] == 1 { + queue[queue_length] = hash_index as u32; + queue_length += 1; + } + } + } + + stack_size +} + +fn assign_fingerprints>( + fingerprints: &mut [T], + segment_length: usize, + stack_hash: &[u64], + stack_index: &[u32], + fingerprint_of: impl Fn(u64) -> T, +) { + for (&hash, &index) in stack_hash.iter().zip(stack_index).rev() { + let index = index as usize; + let [h0, h1, h2] = indexes(hash, segment_length); + fingerprints[index] = + fingerprint_of(hash) ^ fingerprints[h0] ^ fingerprints[h1] ^ fingerprints[h2]; + } +} + +#[inline] +fn indexes(hash: u64, segment_length: usize) -> [usize; 3] { + [ + reduce(hash as u32, segment_length), + reduce(hash.rotate_left(21) as u32, segment_length) + segment_length, + reduce(hash.rotate_left(42) as u32, segment_length) + 2 * segment_length, + ] +} + +#[inline] +fn reduce(hash: u32, range: usize) -> usize { + ((u64::from(hash) * range as u64) >> 32) as usize +} + +#[inline] +fn fingerprint(hash: u64) -> u16 { + (hash ^ (hash >> 32)) as u16 +} + +#[inline] +fn mix(key: u64, seed: u64) -> u64 { + let mut hash = key.wrapping_add(seed); + hash ^= hash >> 33; + hash = hash.wrapping_mul(MURMUR_C1); + hash ^= hash >> 33; + hash = hash.wrapping_mul(MURMUR_C2); + hash ^ (hash >> 33) +} + +fn splitmix64(state: u64) -> u64 { + let mut value = state; + value = (value ^ (value >> 30)).wrapping_mul(SPLITMIX_MUL1); + value = (value ^ (value >> 27)).wrapping_mul(SPLITMIX_MUL2); + value ^ (value >> 31) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn capacity_matches_reference_values() { + assert_eq!(compute_capacity(0).unwrap(), 30); + assert_eq!(compute_capacity(1).unwrap(), 33); + assert_eq!(compute_capacity(5).unwrap(), 36); + assert_eq!(compute_capacity(10_000).unwrap(), 12_330); + } + + #[test] + fn reduce_stays_in_range() { + for hash in [0, 1, u32::MAX / 2, u32::MAX] { + assert!(reduce(hash, 17) < 17); + } + } +} diff --git a/datasketches/src/xor/mod.rs b/datasketches/src/xor/mod.rs new file mode 100644 index 0000000..70e93cc --- /dev/null +++ b/datasketches/src/xor/mod.rs @@ -0,0 +1,62 @@ +// 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. + +//! Xor filters for immutable probabilistic set membership. +//! +//! An xor filter is built from the complete input set and cannot be updated afterward. Compared +//! with a Bloom filter at the same false-positive probability, it generally needs less space and +//! performs exactly three fingerprint reads per query. Construction needs temporary space linear +//! in the number of distinct hashes. +//! +//! The [`XorFilterBuilder`] hashes ordinary Rust values with xxHash64. Call +//! [`XorFilterBuilder::update_hash`] or [`XorFilter::from_hashes`] when hashes have already been +//! computed and must not be hashed again. +//! +//! # Cross-language hashing +//! +//! The serialized filter representation is portable, but Rust's [`Hash`](std::hash::Hash) +//! implementations do not always encode values like other languages. Use the strategies in +//! [`crate::hash::value`] when the input hashes must match another DataSketches implementation. +//! A filter built through the precomputed-hash APIs remains portable only when every reader uses +//! the same external hash function. +//! +//! # Examples +//! +//! ``` +//! use datasketches::xor::XorFilterBuilder; +//! use datasketches::xor::XorFilterType; +//! +//! let mut builder = XorFilterBuilder::new(XorFilterType::Xor8); +//! builder.update("apple"); +//! builder.update("banana"); +//! let filter = builder.build().unwrap(); +//! +//! assert!(filter.contains(&"apple")); +//! assert!(!filter.contains(&"grape")); +//! ``` +//! +//! # References +//! +//! * Graf and Lemire, "Xor Filters: Faster and Smaller Than Bloom and Cuckoo Filters," ACM Journal +//! of Experimental Algorithmics 25 (2020). + +mod filter; +mod serialization; + +pub use self::filter::XorFilter; +pub use self::filter::XorFilterBuilder; +pub use self::filter::XorFilterType; diff --git a/datasketches/src/xor/serialization.rs b/datasketches/src/xor/serialization.rs new file mode 100644 index 0000000..04fb5e1 --- /dev/null +++ b/datasketches/src/xor/serialization.rs @@ -0,0 +1,183 @@ +// 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 crate::codec::SketchBytes; +use crate::codec::SketchSlice; +use crate::codec::assert::ensure_preamble_longs_in_range; +use crate::codec::assert::ensure_serial_version_is; +use crate::codec::assert::insufficient_data; +use crate::codec::family::Family; +use crate::error::Error; +use crate::xor::filter::Fingerprints; +use crate::xor::filter::XorFilter; +use crate::xor::filter::XorFilterType; + +const SERIAL_VERSION: u8 = 1; +const NUM_HASHES: u8 = 3; +const PREAMBLE_BYTES: usize = 3 * size_of::(); + +impl XorFilter { + /// Serializes the filter to a byte vector. + /// + /// The format uses Apache DataSketches family ID `22` and is compatible with the corresponding + /// xor-filter format in other DataSketches implementations. + pub fn serialize(&self) -> Vec { + let mut bytes = SketchBytes::with_capacity(self.serialized_size()); + + bytes.write_u8(Family::XORFILTER.min_pre_longs); + bytes.write_u8(SERIAL_VERSION); + bytes.write_u8(Family::XORFILTER.id); + bytes.write_u8(0); // flags + bytes.write_u8(self.bits_per_fingerprint()); + bytes.write_u8(NUM_HASHES); + bytes.write_u16_le(0); // unused + bytes.write_u64_le(self.seed); + bytes.write_i32_le(self.segment_length as i32); + bytes.write_i32_le(self.num_items as i32); + + match &self.fingerprints { + Fingerprints::Xor8(fingerprints) => bytes.write(fingerprints), + Fingerprints::Xor16(fingerprints) => { + for &fingerprint in fingerprints.iter() { + bytes.write_u16_le(fingerprint); + } + } + } + + bytes.into_bytes() + } + + /// Deserializes an owned filter from bytes. + /// + /// # Errors + /// + /// Returns an error if the image is truncated, belongs to another sketch family or version, or + /// contains metadata that could make a membership query index outside the fingerprint payload. + pub fn deserialize(bytes: &[u8]) -> Result { + let mut cursor = SketchSlice::new(bytes); + + let preamble_longs = cursor + .read_u8() + .map_err(insufficient_data("preamble_longs"))?; + let serial_version = cursor + .read_u8() + .map_err(insufficient_data("serial_version"))?; + let family_id = cursor.read_u8().map_err(insufficient_data("family_id"))?; + let _flags = cursor.read_u8().map_err(insufficient_data("flags"))?; + let bits_per_fingerprint = cursor + .read_u8() + .map_err(insufficient_data("bits_per_fingerprint"))?; + let num_hashes = cursor.read_u8().map_err(insufficient_data("num_hashes"))?; + let _unused = cursor.read_u16_le().map_err(insufficient_data("unused"))?; + + Family::XORFILTER.validate_id(family_id)?; + ensure_serial_version_is(SERIAL_VERSION, serial_version)?; + ensure_preamble_longs_in_range( + Family::XORFILTER.min_pre_longs..=Family::XORFILTER.max_pre_longs, + preamble_longs, + )?; + let filter_type = XorFilterType::from_bits(bits_per_fingerprint)?; + if num_hashes != NUM_HASHES { + return Err(Error::deserial(format!( + "invalid number of hashes: expected {NUM_HASHES}, got {num_hashes}" + ))); + } + + let seed = cursor.read_u64_le().map_err(insufficient_data("seed"))?; + let segment_length = cursor + .read_i32_le() + .map_err(insufficient_data("segment_length"))?; + if segment_length <= 0 { + return Err(Error::deserial(format!( + "invalid segment length: expected a positive value, got {segment_length}" + ))); + } + let segment_length = segment_length as usize; + + let num_items = cursor + .read_i32_le() + .map_err(insufficient_data("num_items"))?; + if num_items < 0 { + return Err(Error::deserial(format!( + "invalid item count: expected a non-negative value, got {num_items}" + ))); + } + let num_items = num_items as usize; + + let capacity = segment_length + .checked_mul(usize::from(NUM_HASHES)) + .ok_or_else(|| Error::deserial("xor filter capacity overflow"))?; + if capacity > i32::MAX as usize { + return Err(Error::deserial(format!( + "invalid xor filter capacity: maximum is {}, got {capacity}", + i32::MAX + ))); + } + if num_items > capacity { + return Err(Error::deserial(format!( + "invalid item count: capacity is {capacity}, got {num_items}" + ))); + } + + let fingerprint_bytes = capacity + .checked_mul(filter_type.bytes_per_fingerprint()) + .ok_or_else(|| Error::deserial("xor filter fingerprint size overflow"))?; + if cursor.remaining().len() < fingerprint_bytes { + return Err(Error::insufficient_data_of( + "fingerprints", + format!( + "expected {fingerprint_bytes} bytes, got {}", + cursor.remaining().len() + ), + )); + } + + let fingerprints = match filter_type { + XorFilterType::Xor8 => { + let mut values = vec![0_u8; capacity].into_boxed_slice(); + cursor + .read_exact(&mut values) + .map_err(insufficient_data("fingerprints"))?; + Fingerprints::Xor8(values) + } + XorFilterType::Xor16 => { + let mut values = Vec::with_capacity(capacity); + for _ in 0..capacity { + values.push( + cursor + .read_u16_le() + .map_err(insufficient_data("fingerprints"))?, + ); + } + Fingerprints::Xor16(values.into_boxed_slice()) + } + }; + + Ok(Self { + filter_type, + segment_length, + num_items, + seed, + fingerprints, + }) + } + + /// Returns the serialized size of the filter in bytes. + pub fn serialized_size(&self) -> usize { + PREAMBLE_BYTES + self.fingerprints.byte_len() + } +} diff --git a/datasketches/tests/serde_tests.rs b/datasketches/tests/serde_tests.rs index 5f31c2d..225ea53 100644 --- a/datasketches/tests/serde_tests.rs +++ b/datasketches/tests/serde_tests.rs @@ -73,3 +73,7 @@ mod theta; #[cfg(feature = "tuple")] #[path = "serde_tests/tuple.rs"] mod tuple; + +#[cfg(feature = "xor")] +#[path = "serde_tests/xor.rs"] +mod xor; diff --git a/datasketches/tests/serde_tests/xor.rs b/datasketches/tests/serde_tests/xor.rs new file mode 100644 index 0000000..dc602bc --- /dev/null +++ b/datasketches/tests/serde_tests/xor.rs @@ -0,0 +1,154 @@ +// 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 datasketches::error::ErrorKind; +use datasketches::xor::XorFilter; +use datasketches::xor::XorFilterBuilder; +use datasketches::xor::XorFilterType; + +const JAVA_XOR8_IMAGE: &str = concat!( + "0301160008030000956eeb2f2632d7bd0c00000004000000", + "00000000000000000000c500000000000000000000000000dd000000040000000b000000" +); +const JAVA_XOR16_IMAGE: &str = concat!( + "0301160010030000", + "956eeb2f2632d7bd", + "0c00000004000000", + "0000000000000000", + "0000000000000000", + "00000000c5b60000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "dd6e000000000000", + "04e8000000000000", + "0b23000000000000", +); + +#[test] +fn java_images_match_byte_for_byte() { + let values = [1_u64, 2, 3, 1_u64 << 63]; + + for (filter_type, image) in [ + (XorFilterType::Xor8, JAVA_XOR8_IMAGE), + (XorFilterType::Xor16, JAVA_XOR16_IMAGE), + ] { + let expected = decode_hex(image); + let mut builder = XorFilterBuilder::new(filter_type).seed(42); + builder.extend(values); + assert_eq!(builder.build().unwrap().serialize(), expected); + + let restored = XorFilter::deserialize(&expected).unwrap(); + assert_eq!(restored.filter_type(), filter_type); + assert_eq!(restored.num_items(), values.len()); + for value in values { + assert!( + restored.contains(&value), + "{filter_type:?} image did not contain {value}" + ); + } + } +} + +#[test] +fn serialization_round_trips_both_fingerprint_types() { + for filter_type in [XorFilterType::Xor8, XorFilterType::Xor16] { + let original = XorFilter::from_hashes(0..10_000_u64, filter_type).unwrap(); + let bytes = original.serialize(); + assert_eq!(bytes.len(), original.serialized_size()); + + let restored = XorFilter::deserialize(&bytes).unwrap(); + assert_eq!(restored, original); + for hash in 0..10_000_u64 { + assert!(restored.contains_hash(hash)); + } + } +} + +#[test] +fn every_truncated_image_is_rejected() { + for filter_type in [XorFilterType::Xor8, XorFilterType::Xor16] { + let bytes = XorFilter::from_hashes(0..100_u64, filter_type) + .unwrap() + .serialize(); + for end in 0..bytes.len() { + let error = XorFilter::deserialize(&bytes[..end]).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::InvalidData, "length {end}"); + } + assert!(XorFilter::deserialize(&bytes).is_ok()); + } +} + +#[test] +fn invalid_preamble_fields_are_rejected() { + let valid = XorFilter::from_hashes(0..100_u64, XorFilterType::Xor8) + .unwrap() + .serialize(); + + for (offset, value) in [(0, 2), (0, 4), (1, 2), (2, 21), (4, 7), (4, 32), (5, 4)] { + let mut corrupted = valid.clone(); + corrupted[offset] = value; + let error = XorFilter::deserialize(&corrupted).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::InvalidData, "offset {offset}"); + } +} + +#[test] +fn unsafe_lengths_are_rejected_before_indexing() { + let valid = XorFilter::from_hashes(0..100_u64, XorFilterType::Xor8) + .unwrap() + .serialize(); + + for segment_length in [0_i32, -1] { + let mut corrupted = valid.clone(); + corrupted[16..20].copy_from_slice(&segment_length.to_le_bytes()); + assert_eq!( + XorFilter::deserialize(&corrupted).unwrap_err().kind(), + ErrorKind::InvalidData + ); + } + + for num_items in [-1_i32, i32::MAX] { + let mut corrupted = valid.clone(); + corrupted[20..24].copy_from_slice(&num_items.to_le_bytes()); + assert_eq!( + XorFilter::deserialize(&corrupted).unwrap_err().kind(), + ErrorKind::InvalidData + ); + } +} + +#[test] +fn trailing_storage_is_ignored() { + let original = XorFilter::from_hashes(0..100_u64, XorFilterType::Xor16).unwrap(); + let mut bytes = original.serialize(); + bytes.extend_from_slice(&[0xaa; 16]); + + assert_eq!(XorFilter::deserialize(&bytes).unwrap(), original); +} + +fn decode_hex(input: &str) -> Vec { + assert_eq!(input.len() % 2, 0); + input + .as_bytes() + .chunks_exact(2) + .map(|digits| { + let digits = std::str::from_utf8(digits).unwrap(); + u8::from_str_radix(digits, 16).unwrap() + }) + .collect() +} diff --git a/datasketches/tests/xor_test/filter.rs b/datasketches/tests/xor_test/filter.rs new file mode 100644 index 0000000..31f633f --- /dev/null +++ b/datasketches/tests/xor_test/filter.rs @@ -0,0 +1,142 @@ +// 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 datasketches::xor::XorFilter; +use datasketches::xor::XorFilterBuilder; +use datasketches::xor::XorFilterType; + +#[test] +fn values_have_no_false_negatives() { + let mut builder = XorFilterBuilder::new(XorFilterType::Xor8).seed(8123); + for value in 0..10_000_u64 { + builder.update(value); + } + let filter = builder.build().unwrap(); + + for value in 0..10_000_u64 { + assert!(filter.contains(&value), "false negative for {value}"); + } +} + +#[test] +fn precomputed_hashes_have_no_false_negatives() { + let hashes = (0..10_000_u64) + .map(|value| value.wrapping_mul(0x9e37_79b9_7f4a_7c15)) + .collect::>(); + + for filter_type in [XorFilterType::Xor8, XorFilterType::Xor16] { + let filter = XorFilter::from_hashes(hashes.iter().copied(), filter_type).unwrap(); + for &hash in &hashes { + assert!(filter.contains_hash(hash), "false negative for {hash}"); + } + } +} + +#[test] +fn builder_accepts_mixed_value_types() { + let mut builder = XorFilterBuilder::new(XorFilterType::Xor16); + builder.update("datasketches"); + builder.update(42_u64); + builder.update([1_u8, 2, 3, 4]); + let filter = builder.build().unwrap(); + + assert!(filter.contains("datasketches")); + assert!(filter.contains(&42_u64)); + assert!(filter.contains(&[1_u8, 2, 3, 4])); +} + +#[test] +fn duplicates_are_removed_before_construction() { + let mut builder = XorFilterBuilder::new(XorFilterType::Xor8); + builder.extend_hashes([7, 7, 11, 11, 11]); + assert_eq!(builder.num_items(), 5); + + let filter = builder.build().unwrap(); + assert_eq!(filter.num_items(), 2); + assert!(filter.contains_hash(7)); + assert!(filter.contains_hash(11)); +} + +#[test] +fn construction_is_independent_of_input_order() { + let ascending = XorFilterBuilder::new(XorFilterType::Xor8).seed(424_242); + let descending = ascending.clone(); + + let mut ascending = ascending; + ascending.extend_hashes(0..50_000_u64); + let mut descending = descending; + descending.extend_hashes((0..50_000_u64).rev()); + + assert_eq!( + ascending.build().unwrap().serialize(), + descending.build().unwrap().serialize() + ); +} + +#[test] +fn metadata_describes_the_compact_payload() { + let filter = XorFilter::from_hashes(0..10_000_u64, XorFilterType::Xor8).unwrap(); + + assert!(!filter.is_empty()); + assert_eq!(filter.num_items(), 10_000); + assert_eq!(filter.filter_type(), XorFilterType::Xor8); + assert_eq!(filter.bits_per_fingerprint(), 8); + assert_eq!(filter.num_hashes(), 3); + assert_eq!(filter.capacity() % 3, 0); + assert!((9.5..10.5).contains(&filter.bits_per_item())); + assert!(filter.estimated_size() >= filter.capacity()); + assert_eq!(filter.serialized_size(), 24 + filter.capacity()); +} + +#[test] +fn empty_filter_is_well_formed() { + let builder = XorFilterBuilder::new(XorFilterType::Xor16); + assert!(builder.is_empty()); + let filter = builder.build().unwrap(); + + assert!(filter.is_empty()); + assert_eq!(filter.num_items(), 0); + assert_eq!(filter.capacity(), 30); + assert_eq!(filter.bits_per_item(), 0.0); + assert_eq!(filter.serialized_size(), 24 + 60); +} + +#[test] +fn false_positive_rates_follow_fingerprint_width() { + const NUM_ITEMS: u64 = 50_000; + const NUM_QUERIES: u64 = 100_000; + + let xor8 = XorFilter::from_hashes(0..NUM_ITEMS, XorFilterType::Xor8).unwrap(); + let xor16 = XorFilter::from_hashes(0..NUM_ITEMS, XorFilterType::Xor16).unwrap(); + + let false_positives8 = (NUM_ITEMS..NUM_ITEMS + NUM_QUERIES) + .filter(|&hash| xor8.contains_hash(hash)) + .count(); + let false_positives16 = (NUM_ITEMS..NUM_ITEMS + NUM_QUERIES) + .filter(|&hash| xor16.contains_hash(hash)) + .count(); + + assert!( + false_positives8 < 1_000, + "8-bit false-positive count was {false_positives8}" + ); + assert!( + false_positives16 < 100, + "16-bit false-positive count was {false_positives16}" + ); + assert!(false_positives16 < false_positives8); +} diff --git a/datasketches/tests/xor_test/main.rs b/datasketches/tests/xor_test/main.rs new file mode 100644 index 0000000..2b29745 --- /dev/null +++ b/datasketches/tests/xor_test/main.rs @@ -0,0 +1,18 @@ +// 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. + +mod filter;