Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ All significant changes to this project will be documented in this file.
### New features

* Add `HllUnion::serialize` to serialize union results directly.
* Add immutable `XorFilter` behind the `xor` feature, with 8-, 16-, and 32-bit fingerprints. Build from distinct precomputed hashes or import a seed and fingerprints with `from_fingerprints`. `XorFilterBuilder` accepts values or hashes individually or in batches. Inputs may repeat. Serialization stores no item count; readers must support the encoded fingerprint width.
* Add `XorFilter` and `XorFilterBuilder` behind the `xor` feature for immutable membership queries on values or precomputed hashes. Filters support 8-, 16-, and 32-bit fingerprints and can be serialized for later queries.

### Improvements

Expand Down
2 changes: 0 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,6 @@ cargo x prepare-testdata java

If no language is specified, all languages are prepared. These directories are not stored in Git. Run the command before the first test run and again whenever the pinned TCK revision changes. It requires network access and replaces the selected generated directories.

The pinned TCK revision does not yet contain XOR filter snapshots. Until it does, `serde_tests/xor.rs` retains small hexadecimal fixtures generated from the Java implementation, with the source revision and inputs documented alongside them. Keep these separate from the synchronized `*_generated_files` directories; replace them with TCK fixtures when available.

## Code of Conduct

We expect all community members to follow our [Code of Conduct](https://www.apache.org/foundation/policies/conduct.html).
1 change: 0 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ See the [API documentation](https://docs.rs/datasketches) for configuration, acc

The minimum supported Rust version is 1.86.0. The crate currently supports little-endian targets only.

Supported serialization formats are tested with fixtures produced by Apache DataSketches Java, C++, and Go through the [DataSketches TCK](https://github.com/apache/datasketches-tck). XOR also uses Java fixtures until TCK snapshots are available. Its standard format supports 8-, 16-, and 32-bit fingerprints; readers must support the encoded width. Use `XorFilter::from_fingerprints` to import a seed and fingerprints from another format.
Cross-language serialization tests use Java, C++, and Go fixtures from the [DataSketches TCK](https://github.com/apache/datasketches-tck).

Serialization compatibility does not imply that an ordinary Rust `Hash` implementation produces the same update bytes across languages. When sketches must represent the same inputs across implementations, use `hash::value::{raw_bytes, canonical_float, sign_extend, natural_extend}` (and the constructors within those modules) to select matching input encodings. When interoperating with an implementation that skips empty strings, skip them before updating here as well.

Expand Down
15 changes: 2 additions & 13 deletions benchmarks/xor/query_shards.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,14 @@ use benchmarks::Bencher;
use benchmarks::Runner;
use datasketches::xor::XorFilter;
use datasketches::xor::XorFilterType;
use datasketches::xor::XorFingerprints;
use xorf::Filter;

use crate::xor::hashes_with_offset;

const SHARDS: usize = 32;
const QUERIES: usize = 256;

// Storage formats can choose a fingerprint width at runtime. Give both peers
// this dispatch, and import identical payloads.
// Both implementations dispatch on fingerprint width at runtime.
enum Reference {
Xor8(xorf::Xor8),
Xor16(xorf::Xor16),
Expand All @@ -54,15 +52,6 @@ impl Reference {
Self::Xor32(filter) => filter.contains(&key),
}
}

fn into_datasketches(self) -> XorFilter {
let (seed, fingerprints) = match self {
Self::Xor8(f) => (f.seed, XorFingerprints::Xor8(f.fingerprints)),
Self::Xor16(f) => (f.seed, XorFingerprints::Xor16(f.fingerprints)),
Self::Xor32(f) => (f.seed, XorFingerprints::Xor32(f.fingerprints)),
};
XorFilter::from_fingerprints(seed, fingerprints).unwrap()
}
}

fn query<F>(
Expand Down Expand Up @@ -116,7 +105,7 @@ pub fn register(runner: &mut Runner) {
b,
cardinality,
present,
|keys| Reference::new(keys, kind).into_datasketches(),
|keys| XorFilter::from_hashes(keys, kind, 1).unwrap(),
XorFilter::contains_hash,
)
}),
Expand Down
124 changes: 24 additions & 100 deletions datasketches/src/xor/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,20 +72,14 @@ impl XorFilterType {
}
}

/// Owned fingerprint slots for importing and exporting an application-defined format.
///
/// Slots are ordered as three consecutive, equally sized segments.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum XorFingerprints {
/// 8-bit fingerprint slots.
enum Fingerprints {
Xor8(Box<[u8]>),
/// 16-bit fingerprint slots.
Xor16(Box<[u16]>),
/// 32-bit fingerprint slots.
Xor32(Box<[u32]>),
}

impl XorFingerprints {
impl Fingerprints {
fn len(&self) -> usize {
match self {
Self::Xor8(values) => values.len(),
Expand All @@ -105,8 +99,6 @@ impl XorFingerprints {

/// An [`XorFilter`] builder for values or precomputed hashes.
///
/// Inputs may repeat. Temporary memory use can grow linearly with the total input count.
///
/// Query values with [`XorFilter::contains`] and precomputed hashes with
/// [`XorFilter::contains_hash`].
#[derive(Debug)]
Expand Down Expand Up @@ -151,11 +143,11 @@ impl XorFilterBuilder {
self.hashes.extend(hashes);
}

/// Consumes the builder and creates a filter from the distinct hashes.
/// Consumes the builder and creates the filter.
///
/// # Errors
///
/// Returns an error if the set of distinct hashes is too large or no valid layout is found.
/// Returns an error if the input is too large or no valid layout is found.
pub fn build(mut self) -> Result<XorFilter, Error> {
self.hashes.sort_unstable();
self.hashes.dedup();
Expand All @@ -174,7 +166,7 @@ pub struct XorFilter {
// Cache the derived length to avoid division on every query.
segment_length: usize,
seed: u64,
fingerprints: XorFingerprints,
fingerprints: Fingerprints,
}

impl XorFilter {
Expand Down Expand Up @@ -249,21 +241,21 @@ impl XorFilter {
drop(queue);

let fingerprints = match filter_type {
XorFilterType::Xor8 => XorFingerprints::Xor8(assign_fingerprints(
XorFilterType::Xor8 => Fingerprints::Xor8(assign_fingerprints(
capacity,
segment_length,
&stack_hash,
&stack_index,
|hash| fingerprint(hash) as u8,
)),
XorFilterType::Xor16 => XorFingerprints::Xor16(assign_fingerprints(
XorFilterType::Xor16 => Fingerprints::Xor16(assign_fingerprints(
capacity,
segment_length,
&stack_hash,
&stack_index,
|hash| fingerprint(hash) as u16,
)),
XorFilterType::Xor32 => XorFingerprints::Xor32(assign_fingerprints(
XorFilterType::Xor32 => Fingerprints::Xor32(assign_fingerprints(
capacity,
segment_length,
&stack_hash,
Expand All @@ -279,57 +271,6 @@ impl XorFilter {
})
}

/// Imports a filter from its stored seed and fingerprint slots.
///
/// Pass the original filter's [`seed`](Self::seed), which can differ from the initial
/// construction seed. Preserve the fingerprint order and query with the original input hashes.
///
/// # Errors
///
/// Returns an error unless the payload has three equal segments of `1..=u32::MAX` slots each.
///
/// # Examples
///
/// ```
/// use datasketches::xor::XorFilter;
/// use datasketches::xor::XorFilterType;
///
/// let filter = XorFilter::from_hashes(&[10, 20, 30], XorFilterType::Xor8, 0)?;
/// let imported = XorFilter::from_fingerprints(filter.seed(), filter.fingerprints().clone())?;
/// assert!(imported.contains_hash(20));
/// # Ok::<(), datasketches::error::Error>(())
/// ```
pub fn from_fingerprints(seed: u64, fingerprints: XorFingerprints) -> Result<Self, Error> {
let slots = fingerprints.len();
if slots == 0 {
return Err(Error::invalid_argument(
"xor filter fingerprint array must not be empty",
));
}
if slots % 3 != 0 {
return Err(Error::invalid_argument(format!(
"xor filter fingerprint slot count must be a multiple of 3, got {slots}"
)));
}
let segment_length = slots / 3;
if segment_length > u32::MAX as usize {
return Err(Error::invalid_argument(format!(
"xor filter segment length must be at most {}, got {segment_length}",
u32::MAX
)));
}
Ok(Self {
segment_length,
seed,
fingerprints,
})
}

/// Returns the fingerprint slots for application-defined serialization.
pub fn fingerprints(&self) -> &XorFingerprints {
&self.fingerprints
}

/// 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
Expand All @@ -349,48 +290,38 @@ impl XorFilter {
let [h0, h1, h2] = indexes(mixed, self.segment_length);

match &self.fingerprints {
XorFingerprints::Xor8(fingerprints) => {
Fingerprints::Xor8(fingerprints) => {
fingerprint(mixed) as u8 == fingerprints[h0] ^ fingerprints[h1] ^ fingerprints[h2]
}
XorFingerprints::Xor16(fingerprints) => {
Fingerprints::Xor16(fingerprints) => {
fingerprint(mixed) as u16 == fingerprints[h0] ^ fingerprints[h1] ^ fingerprints[h2]
}
XorFingerprints::Xor32(fingerprints) => {
Fingerprints::Xor32(fingerprints) => {
fingerprint(mixed) as u32 == fingerprints[h0] ^ fingerprints[h1] ^ fingerprints[h2]
}
}
}

/// Returns the fingerprint representation.
pub fn filter_type(&self) -> XorFilterType {
fn filter_type(&self) -> XorFilterType {
match &self.fingerprints {
XorFingerprints::Xor8(_) => XorFilterType::Xor8,
XorFingerprints::Xor16(_) => XorFilterType::Xor16,
XorFingerprints::Xor32(_) => XorFilterType::Xor32,
Fingerprints::Xor8(_) => XorFilterType::Xor8,
Fingerprints::Xor16(_) => XorFilterType::Xor16,
Fingerprints::Xor32(_) => XorFilterType::Xor32,
}
}

/// Returns the seed paired with these fingerprints for queries and imports.
pub fn seed(&self) -> u64 {
self.seed
}

/// Returns the estimated in-memory size of the filter in bytes.
pub fn estimated_size(&self) -> usize {
size_of::<Self>() + self.fingerprints.byte_len()
}

/// Serializes the filter using DataSketches family `22`, version `1`.
///
/// Supports 8-, 16-, and 32-bit fingerprints, recorded in the header's fingerprint width
/// field. Integers and fingerprints are little-endian. Readers must support the encoded
/// fingerprint width; implementations limited to 8/16 bits cannot read 32-bit images.
/// Serializes the filter in the DataSketches format.
///
/// # Errors
///
/// Returns [`InvalidArgument`](crate::error::ErrorKind::InvalidArgument) if the fingerprint
/// slot count exceeds `i32::MAX` or the serialized size overflows `usize`. Construction and
/// imports allow larger capacities than the standard format.
/// slot count exceeds `i32::MAX` or the serialized size overflows `usize`. Construction allows
/// larger capacities than the standard format.
pub fn serialize(&self) -> Result<Vec<u8>, Error> {
if self.fingerprints.len() > i32::MAX as usize {
return Err(Error::invalid_argument(
Expand All @@ -410,13 +341,13 @@ impl XorFilter {
bytes.write_u64_le(self.seed);

match &self.fingerprints {
XorFingerprints::Xor8(fingerprints) => bytes.write(fingerprints),
XorFingerprints::Xor16(fingerprints) => {
Fingerprints::Xor8(fingerprints) => bytes.write(fingerprints),
Fingerprints::Xor16(fingerprints) => {
for &fingerprint in fingerprints.iter() {
bytes.write_u16_le(fingerprint);
}
}
XorFingerprints::Xor32(fingerprints) => {
Fingerprints::Xor32(fingerprints) => {
for &fingerprint in fingerprints.iter() {
bytes.write_u32_le(fingerprint);
}
Expand All @@ -428,7 +359,6 @@ impl XorFilter {

/// Deserializes an owned filter from bytes.
///
/// Accepts 8-, 16-, and 32-bit images within the limits of [`serialize`](Self::serialize).
/// Trailing bytes are ignored.
///
/// # Errors
Expand Down Expand Up @@ -501,13 +431,7 @@ impl XorFilter {
}

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"))?;
XorFingerprints::Xor8(values)
}
XorFilterType::Xor8 => Fingerprints::Xor8(cursor.remaining()[..capacity].into()),
XorFilterType::Xor16 => {
let mut values = Vec::with_capacity(capacity);
for _ in 0..capacity {
Expand All @@ -517,7 +441,7 @@ impl XorFilter {
.map_err(insufficient_data("fingerprints"))?,
);
}
XorFingerprints::Xor16(values.into_boxed_slice())
Fingerprints::Xor16(values.into_boxed_slice())
}
XorFilterType::Xor32 => {
let mut values = Vec::with_capacity(capacity);
Expand All @@ -528,7 +452,7 @@ impl XorFilter {
.map_err(insufficient_data("fingerprints"))?,
);
}
XorFingerprints::Xor32(values.into_boxed_slice())
Fingerprints::Xor32(values.into_boxed_slice())
}
};

Expand Down
26 changes: 5 additions & 21 deletions datasketches/src/xor/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,35 +17,20 @@

//! 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.
//!
//! [`XorFilterBuilder`] accepts values or precomputed hashes, including duplicates.
//! Use [`XorFilter::from_hashes`] when the hashes are already distinct.
//! Use [`XorFilterBuilder`] to build a filter from values or precomputed hashes. A negative
//! query result rules out membership. A positive result may be a false positive.
//!
//! # Cross-language hashing
//!
//! Standard serialization supports 8-, 16-, and 32-bit fingerprints using family `22`, version `1`.
//! Interoperability requires the reader to support the encoded fingerprint width. The format
//! stores the seed, segment layout, and fingerprints, without an item count.
//! Construction may choose a different valid fingerprint array from another implementation;
//! byte-for-byte equality of independently constructed filters is not guaranteed. 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.
//! Rust's [`Hash`](std::hash::Hash) may encode a value differently from other languages. Use
//! [`crate::hash::value`] to match the input encoding of another DataSketches implementation.
//! For precomputed hashes, use the same hash function when building and querying the filter.
//!
//! Java hashes integer scalars as 64-bit little-endian values and strings as UTF-8 bytes with
//! xxHash64 seed `0`. Use `u64`/`i64` and [`crate::hash::value::raw_bytes`] accordingly. Java
//! skips empty strings; callers must do so explicitly here. External hashes (for example XXH3)
//! must use the precomputed-hash APIs throughout; Java's typed query API will not match them.
//!
//! # Application-defined storage
//!
//! [`XorFilter::from_fingerprints`] restores a filter from its stored [`seed`](XorFilter::seed)
//! and [`fingerprints`](XorFilter::fingerprints).
//!
//! # Examples
//!
//! ```
Expand All @@ -69,4 +54,3 @@ mod filter;
pub use self::filter::XorFilter;
pub use self::filter::XorFilterBuilder;
pub use self::filter::XorFilterType;
pub use self::filter::XorFingerprints;
1 change: 0 additions & 1 deletion tests-integration/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ edition.workspace = true
rust-version.workspace = true

[dev-dependencies]
xorf = { version = "=0.13.0", default-features = false }
datasketches = { workspace = true, features = [
"bloom",
"countmin",
Expand Down
Loading
Loading