Skip to content

Commit f06b4e8

Browse files
committed
feat: add tuple sketch implementation
1 parent ee6e1cc commit f06b4e8

20 files changed

Lines changed: 2418 additions & 56 deletions

datasketches/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ frequencies = []
4545
hll = []
4646
tdigest = []
4747
theta = []
48+
tuple = []
4849

4950
[dev-dependencies]
5051
googletest = { workspace = true }

datasketches/src/codec/decode.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,16 @@ impl SketchSlice<'_> {
3838
self.slice.set_position(pos + n);
3939
}
4040

41+
/// Returns the not-yet-read portion of the underlying slice.
42+
///
43+
/// Useful for handing the remaining bytes to a variable-length decoder that reports how many
44+
/// bytes it consumed; pair it with [`advance`](Self::advance).
45+
pub fn remaining(&self) -> &[u8] {
46+
let buf = self.slice.get_ref();
47+
let pos = (self.slice.position() as usize).min(buf.len());
48+
&buf[pos..]
49+
}
50+
4151
/// Reads exactly `buf.len()` bytes from the slice into `buf`.
4252
pub fn read_exact(&mut self, buf: &mut [u8]) -> io::Result<()> {
4353
self.slice.read_exact(buf)

datasketches/src/codec/family.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,15 @@ impl Family {
5353
max_pre_longs: 1,
5454
};
5555

56+
/// Tuple Sketch for cardinality estimation with per-key summaries.
57+
#[cfg(feature = "tuple")]
58+
pub const TUPLE: Family = Family {
59+
id: 9,
60+
name: "TUPLE",
61+
min_pre_longs: 1,
62+
max_pre_longs: 3,
63+
};
64+
5665
/// The Frequency family of sketches.
5766
#[cfg(feature = "frequencies")]
5867
pub const FREQUENCY: Family = Family {

datasketches/src/codec/mod.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ pub use self::encode::SketchBytes;
2929
feature = "frequencies",
3030
feature = "hll",
3131
feature = "tdigest",
32-
feature = "theta"
32+
feature = "theta",
33+
feature = "tuple"
3334
))]
3435
#[allow(dead_code)] // some utilities are only used for certain sketches
3536
pub(crate) mod assert;
@@ -41,6 +42,7 @@ pub(crate) mod assert;
4142
feature = "frequencies",
4243
feature = "hll",
4344
feature = "tdigest",
44-
feature = "theta"
45+
feature = "theta",
46+
feature = "tuple"
4547
))]
4648
pub(crate) mod family;

datasketches/src/hash/mod.rs

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,17 @@
2020
feature = "cpc",
2121
feature = "frequencies",
2222
feature = "hll",
23-
feature = "theta"
23+
feature = "theta",
24+
feature = "tuple"
2425
))]
2526
mod murmurhash;
2627
#[cfg(any(
2728
feature = "countmin",
2829
feature = "cpc",
2930
feature = "frequencies",
3031
feature = "hll",
31-
feature = "theta"
32+
feature = "theta",
33+
feature = "tuple"
3234
))]
3335
pub(crate) use self::murmurhash::MurmurHash3X64128;
3436

@@ -56,7 +58,8 @@ pub(crate) use self::xxhash::XxHash64;
5658
feature = "cpc",
5759
feature = "frequencies",
5860
feature = "hll",
59-
feature = "theta"
61+
feature = "theta",
62+
feature = "tuple"
6063
))]
6164
pub(crate) const DEFAULT_UPDATE_SEED: u64 = 9001;
6265

@@ -68,7 +71,12 @@ pub(crate) const DEFAULT_UPDATE_SEED: u64 = 9001;
6871
/// # Panics
6972
///
7073
/// Panics if the computed seed hash is zero.
71-
#[cfg(any(feature = "countmin", feature = "cpc", feature = "theta"))]
74+
#[cfg(any(
75+
feature = "countmin",
76+
feature = "cpc",
77+
feature = "theta",
78+
feature = "tuple"
79+
))]
7280
pub(crate) fn compute_seed_hash(seed: u64) -> u16 {
7381
use std::hash::Hasher;
7482

@@ -91,7 +99,8 @@ pub(crate) fn compute_seed_hash(seed: u64) -> u16 {
9199
feature = "cpc",
92100
feature = "frequencies",
93101
feature = "hll",
94-
feature = "theta"
102+
feature = "theta",
103+
feature = "tuple"
95104
))]
96105
fn read_u64_le(bytes: &[u8]) -> u64 {
97106
let mut buf = [0u8; 8];

datasketches/src/lib.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,10 @@ pub mod hll;
4545
pub mod tdigest;
4646
#[cfg(feature = "theta")]
4747
pub mod theta;
48-
#[cfg(feature = "theta")]
48+
#[cfg(any(feature = "theta", feature = "tuple"))]
4949
pub mod thetacommon;
50+
#[cfg(feature = "tuple")]
51+
pub mod tuple;
5052

5153
// common modules
5254
pub mod codec;

datasketches/src/theta/serialization.rs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,3 @@ pub(super) const COMPRESSED_SERIAL_VERSION: u8 = 4;
2323
pub(super) const V2_PREAMBLE_EMPTY: u8 = 1;
2424
pub(super) const V2_PREAMBLE_PRECISE: u8 = 2;
2525
pub(super) const V2_PREAMBLE_ESTIMATE: u8 = 3;
26-
27-
pub(super) const FLAGS_IS_READ_ONLY: u8 = 1 << 1;
28-
pub(super) const FLAGS_IS_EMPTY: u8 = 1 << 2;
29-
pub(super) const FLAGS_IS_COMPACT: u8 = 1 << 3;
30-
pub(super) const FLAGS_IS_ORDERED: u8 = 1 << 4;

datasketches/src/theta/sketch.rs

Lines changed: 27 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ use crate::theta::serialization::V2_PREAMBLE_PRECISE;
4646
use crate::thetacommon::RawThetaSketchView;
4747
use crate::thetacommon::binomial_bounds;
4848
use crate::thetacommon::constants::DEFAULT_LG_K;
49+
use crate::thetacommon::constants::FLAGS_IS_COMPACT;
50+
use crate::thetacommon::constants::FLAGS_IS_EMPTY;
51+
use crate::thetacommon::constants::FLAGS_IS_ORDERED;
52+
use crate::thetacommon::constants::FLAGS_IS_READ_ONLY;
4953
use crate::thetacommon::constants::MAX_LG_K;
5054
use crate::thetacommon::constants::MAX_THETA;
5155
use crate::thetacommon::constants::MIN_LG_K;
@@ -220,25 +224,18 @@ impl ThetaSketch {
220224
/// assert_eq!(compact.num_retained(), 1);
221225
/// ```
222226
pub fn compact(&self, ordered: bool) -> CompactThetaSketch {
223-
let mut entries: Vec<u64> = self.iter().map(|entry| entry.hash()).collect();
224-
225-
let empty = self.is_empty();
226-
let theta = if empty {
227-
// Match Java's correctThetaOnCompact() behavior for never-updated sketches
228-
// initialized with p < 1.0.
229-
MAX_THETA
230-
} else {
231-
self.table.theta()
232-
};
233-
let is_single = entries.len() == 1 && theta == MAX_THETA;
234-
// Empty or Single-item sketches are always ordered (Java compatibility)
235-
let ordered = ordered || empty || is_single;
236-
237-
if ordered && entries.len() > 1 {
238-
entries.sort_unstable();
239-
}
240-
241-
CompactThetaSketch::from_parts(entries, theta, self.table.seed_hash(), ordered, empty)
227+
let parts = self.table.to_compact_parts(ordered);
228+
CompactThetaSketch::from_parts(
229+
parts
230+
.entries
231+
.into_iter()
232+
.map(|entry| entry.hash())
233+
.collect(),
234+
parts.theta,
235+
parts.seed_hash,
236+
parts.ordered,
237+
parts.empty,
238+
)
242239
}
243240

244241
/// Returns the approximate lower error bound given the specified number of Standard Deviations.
@@ -467,13 +464,13 @@ impl CompactThetaSketch {
467464
bytes.write_u16_be(0); // unused for compact
468465

469466
let mut flags = 0u8;
470-
flags |= serialization::FLAGS_IS_READ_ONLY;
471-
flags |= serialization::FLAGS_IS_COMPACT;
467+
flags |= FLAGS_IS_READ_ONLY;
468+
flags |= FLAGS_IS_COMPACT;
472469
if self.is_empty() {
473-
flags |= serialization::FLAGS_IS_EMPTY;
470+
flags |= FLAGS_IS_EMPTY;
474471
}
475472
if self.is_ordered() {
476-
flags |= serialization::FLAGS_IS_ORDERED;
473+
flags |= FLAGS_IS_ORDERED;
477474
}
478475
bytes.write_u8(flags);
479476

@@ -510,9 +507,9 @@ impl CompactThetaSketch {
510507
bytes.write_u8(num_entries_bytes);
511508

512509
let mut flags = 0u8;
513-
flags |= serialization::FLAGS_IS_READ_ONLY;
514-
flags |= serialization::FLAGS_IS_COMPACT;
515-
flags |= serialization::FLAGS_IS_ORDERED;
510+
flags |= FLAGS_IS_READ_ONLY;
511+
flags |= FLAGS_IS_COMPACT;
512+
flags |= FLAGS_IS_ORDERED;
516513
bytes.write_u8(flags);
517514

518515
bytes.write_u16_le(self.seed_hash);
@@ -748,7 +745,7 @@ impl CompactThetaSketch {
748745
.read_u16_le()
749746
.map_err(insufficient_data("seed_hash"))?;
750747

751-
let empty = (flags & serialization::FLAGS_IS_EMPTY) != 0;
748+
let empty = (flags & FLAGS_IS_EMPTY) != 0;
752749
let mut theta = MAX_THETA;
753750
let num_entries;
754751
let mut entries = vec![];
@@ -776,7 +773,7 @@ impl CompactThetaSketch {
776773
}
777774
entries = Self::read_entries(&mut cursor, num_entries as usize, theta)?;
778775
}
779-
let ordered = (flags & serialization::FLAGS_IS_ORDERED) != 0;
776+
let ordered = (flags & FLAGS_IS_ORDERED) != 0;
780777
Ok(Self {
781778
entries,
782779
theta,
@@ -797,7 +794,7 @@ impl CompactThetaSketch {
797794
let seed_hash = cursor
798795
.read_u16_le()
799796
.map_err(insufficient_data("seed_hash"))?;
800-
let empty = (flags & serialization::FLAGS_IS_EMPTY) != 0;
797+
let empty = (flags & FLAGS_IS_EMPTY) != 0;
801798
if !empty {
802799
let expected_seed_hash = compute_seed_hash(expected_seed);
803800
if seed_hash != expected_seed_hash {
@@ -861,7 +858,7 @@ impl CompactThetaSketch {
861858
}
862859
}
863860

864-
let ordered = (flags & serialization::FLAGS_IS_ORDERED) != 0;
861+
let ordered = (flags & FLAGS_IS_ORDERED) != 0;
865862

866863
Ok(Self {
867864
entries,

datasketches/src/thetacommon/constants.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,13 @@ pub const HASH_TABLE_REBUILD_THRESHOLD: f64 = 15.0 / 16.0;
3434

3535
pub const STRIDE_HASH_BITS: u8 = 7;
3636
pub const STRIDE_MASK: u64 = (1 << STRIDE_HASH_BITS) - 1;
37+
38+
// Flag bits of the flags byte in the Theta-family wire format, shared by Theta and Tuple sketches.
39+
/// Flags byte bit: the sketch is read-only.
40+
pub const FLAGS_IS_READ_ONLY: u8 = 1 << 1;
41+
/// Flags byte bit: the sketch is logically empty.
42+
pub const FLAGS_IS_EMPTY: u8 = 1 << 2;
43+
/// Flags byte bit: the sketch is in compact form.
44+
pub const FLAGS_IS_COMPACT: u8 = 1 << 3;
45+
/// Flags byte bit: retained entries are ordered by ascending hash.
46+
pub const FLAGS_IS_ORDERED: u8 = 1 << 4;

datasketches/src/thetacommon/hash_table.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ use std::hash::Hash;
2020
use crate::common::ResizeFactor;
2121
use crate::hash::MurmurHash3X64128;
2222
use crate::hash::compute_seed_hash;
23+
use crate::thetacommon::RawCompactParts;
2324
use crate::thetacommon::RawHashTableEntry;
2425
use crate::thetacommon::constants::HASH_TABLE_REBUILD_THRESHOLD;
2526
use crate::thetacommon::constants::HASH_TABLE_RESIZE_THRESHOLD;
@@ -168,6 +169,7 @@ where
168169
}
169170

170171
/// Returns a reference to the entry stored for `hash`, or `None` if the hash is not retained.
172+
#[allow(dead_code)] // used by the set operations; tuple set operations arrive in follow-ups
171173
pub fn get_entry(&self, hash: u64) -> Option<&E> {
172174
if hash == 0 {
173175
return None;
@@ -234,6 +236,33 @@ where
234236
self.entries.iter().filter_map(Option::as_ref)
235237
}
236238

239+
/// Returns the retained entries and theta as raw compact-sketch parts.
240+
///
241+
/// An empty table reports `MAX_THETA` rather than its current theta, matching Java's
242+
/// `correctThetaOnCompact()` behavior for never-updated sketches initialized with p < 1.0.
243+
/// Empty and single-entry exact-mode results are always marked ordered (Java/C++
244+
/// compatibility).
245+
pub fn to_compact_parts(&self, ordered: bool) -> RawCompactParts<E>
246+
where
247+
E: Clone,
248+
{
249+
let mut entries: Vec<E> = self.iter_entries().cloned().collect();
250+
let empty = self.is_empty();
251+
let theta = if empty { MAX_THETA } else { self.theta() };
252+
let is_single = entries.len() == 1 && theta == MAX_THETA;
253+
let ordered = ordered || empty || is_single;
254+
if ordered && entries.len() > 1 {
255+
entries.sort_unstable_by_key(RawHashTableEntry::hash);
256+
}
257+
RawCompactParts {
258+
entries,
259+
theta,
260+
seed_hash: self.seed_hash(),
261+
ordered,
262+
empty,
263+
}
264+
}
265+
237266
/// Return number of all entries.
238267
#[cfg(test)]
239268
pub fn num_entries(&self) -> usize {
@@ -257,16 +286,19 @@ where
257286
}
258287

259288
/// Set empty flag.
289+
#[allow(dead_code)] // used by the set operations; tuple set operations arrive in follow-ups
260290
pub fn set_empty(&mut self, is_empty: bool) {
261291
self.is_empty = is_empty;
262292
}
263293

264294
/// Get the hash seed used by this table.
295+
#[allow(dead_code)] // used by the set operations; tuple set operations arrive in follow-ups
265296
pub fn hash_seed(&self) -> u64 {
266297
self.hash_seed
267298
}
268299

269300
/// Sets theta value.
301+
#[allow(dead_code)] // used by the set operations and tests; tuple set operations arrive in follow-ups
270302
pub fn set_theta(&mut self, theta: u64) {
271303
assert!(
272304
(1..=MAX_THETA).contains(&theta),
@@ -276,6 +308,7 @@ where
276308
}
277309

278310
/// Returns minimal lg_size where rebuild-capacity can hold `count`.
311+
#[allow(dead_code)] // used by the set operations; tuple set operations arrive in follow-ups
279312
pub fn lg_size_from_count_for_rebuild(count: usize, load_factor: f64) -> u8 {
280313
let log2 = |n: usize| {
281314
if n == 0 { 0_u8 } else { n.ilog2() as u8 }

0 commit comments

Comments
 (0)