From 1375e2b097399db3357d5202ea03db658d94dd65 Mon Sep 17 00:00:00 2001 From: GordonYuanyc Date: Fri, 16 Oct 2026 05:30:11 +0000 Subject: [PATCH 1/7] feat(cms): ASAPv1 Count-Min delta (02 80) CountMinDelta carries changed cells as ascending indices and non-zero deltas under the state's metadata; CountMin::merge_delta applies one all or nothing. A crate-private delta module holds the cell rules and the checked add that the other delta kinds reuse. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 + asapv1_golden | 2 +- docs/api/api_countmin.md | 20 +++ docs/asapv1_wire_format.md | 4 +- docs/tests.md | 7 + src/message_pack_format/delta.rs | 87 ++++++++++++ src/message_pack_format/envelope.rs | 4 + src/message_pack_format/mod.rs | 1 + src/message_pack_format/wire_key.rs | 5 + src/sketches/countminsketch.rs | 2 + src/sketches/countminsketch/delta.rs | 189 +++++++++++++++++++++++++++ src/sketches/countminsketch/wire.rs | 102 ++++++++------- src/sketches/mod.rs | 2 +- tests/asapv1_golden.rs | 47 ++++++- 14 files changed, 425 insertions(+), 50 deletions(-) create mode 100644 src/message_pack_format/delta.rs create mode 100644 src/sketches/countminsketch/delta.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index ed285188..60808a43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,9 @@ signals a backwards-compatible change. payload is unchanged. - **`peek_kind_id(&[u8])`** returns an ASAPv1 envelope's `kind_id` after checking its magic, version, and framing, without decoding the sketch. +- **`CountMinDelta`**, the ASAPv1 Count-Min delta (`0x02 0x80`): built with + `new` from changed cells, serialized like a sketch, and added with + `CountMin::merge_delta`, which changes nothing when it fails. ### Changed diff --git a/asapv1_golden b/asapv1_golden index 8287a4ae..809fee5e 160000 --- a/asapv1_golden +++ b/asapv1_golden @@ -1 +1 @@ -Subproject commit 8287a4ae04e6cfbfb3ed33dafbf1dac77d8f7fc1 +Subproject commit 809fee5e26e7319ff8b0acc5ccf19c647c80fbfe diff --git a/docs/api/api_countmin.md b/docs/api/api_countmin.md index 3c479961..a09b8f50 100644 --- a/docs/api/api_countmin.md +++ b/docs/api/api_countmin.md @@ -73,6 +73,26 @@ list length): past that, the regular path gives row `r` and row `r + 20` the same seed and identical counters, so a wider matrix is refused on both sides in either mode. +## Deltas + +```rust +CountMinDelta + +fn new(rows: usize, cols: usize, indices: Vec, deltas: Vec) -> Result +fn serialize_to_bytes(&self) -> Result, RmpEncodeError> +fn deserialize_from_bytes(bytes: &[u8]) -> Result + +// on CountMin, Mode, H> +fn merge_delta(&mut self, delta: &CountMinDelta) -> Result<(), String> +``` + +A `CountMinDelta` is the ASAPv1 delta kind `0x02 0x80`, for the same configs +as the state serialization: the changed cells as strictly ascending row-major +indices `row * cols + col`, below `rows * cols`, and the non-zero value added +to each. `new` and the decoder refuse anything else. `merge_delta` adds the +delta to the counters; a delta of other dimensions, or an integer add that +overflows, fails and leaves the sketch unchanged. + ## Examples ```rust diff --git a/docs/asapv1_wire_format.md b/docs/asapv1_wire_format.md index 3532dab5..64a29cd8 100644 --- a/docs/asapv1_wire_format.md +++ b/docs/asapv1_wire_format.md @@ -26,7 +26,7 @@ If the doc feels long, these sections carry the key points: ## Status -- **Implemented (Rust).** Every kind_id the Section 1 registry marks *implemented* serializes through the shared `message_pack_format::envelope` module per this spec: HLL (three estimators), Count-Min, CMSHeap, Count Sketch, CSHeap, DDSketch, KLL (compact + dynamic), Hydra (five counter variants), Elastic, Coco, UniformSampling, KMV, UnivMon, UnivMon Optimized, ExponentialHistogram, EHSketchList, Bloom, Space-Saving, CountL2HH and UnivMon-Q. +- **Implemented (Rust).** Every kind_id the Section 1 registry marks *implemented* serializes through the shared `message_pack_format::envelope` module per this spec: HLL (three estimators), Count-Min with its delta (`CountMinDelta`), CMSHeap, Count Sketch, CSHeap, DDSketch, KLL (compact + dynamic), Hydra (five counter variants), Elastic, Coco, UniformSampling, KMV, UnivMon, UnivMon Optimized, ExponentialHistogram, EHSketchList, Bloom, Space-Saving, CountL2HH and UnivMon-Q. - **Self-describing.** The hash-spec metadata is derived from the hasher's `HashProfile` (read live, never hardcoded), so the bytes truthfully describe how a sketch was hashed; custom hash profiles are supported (Section 2). - **Byte-level encoding** is pinned in Section 4; the resolved decisions are summarized at the end. - **`sketchlib-go`** is aligned separately (see Cross-language contract). @@ -189,7 +189,7 @@ This registry is the master list of algorithms still to design payloads for, and | `0x01 0x81` | HLL | Classic delta | Section 3.23 | designed | | `0x01 0x82` | HLL | Ertl-MLE delta | Section 3.23 | designed | | `0x02 0x00` | Count-Min | Count-Min | Section 3.2 | implemented | -| `0x02 0x80` | Count-Min | Count-Min delta | Section 3.23 | designed | +| `0x02 0x80` | Count-Min | Count-Min delta | Section 3.23 | implemented | | `0x03 0x00` | Count-Min-with-heap (CMSHeap) | Count-Min + top-k heap | Section 3.7 | implemented | | `0x03 0x80` | Count-Min-with-heap (CMSHeap) | CMSHeap delta | Section 3.23 | designed | | `0x04 0x00` | Count Sketch | Count Sketch | Section 3.6 | implemented | diff --git a/docs/tests.md b/docs/tests.md index af47e619..fdda276c 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -31,6 +31,8 @@ Test file: [`src/sketches/countminsketch.rs`](../src/sketches/countminsketch.rs) Wire tests: [`src/sketches/countminsketch/wire.rs`](../src/sketches/countminsketch/wire.rs) +Delta tests: [`src/sketches/countminsketch/delta.rs`](../src/sketches/countminsketch/delta.rs) + | test_name | test_description | what_is_tested | | --- | --- | --- | | `dimension_test` | Default/custom dimensions initialize zeroed counters. | Verifies default dimensions (`rows=3`, `cols=4096`), custom dimensions (`3x17`), and zero-initialized counters after construction. | @@ -50,6 +52,10 @@ Wire tests: [`src/sketches/countminsketch/wire.rs`](../src/sketches/countminsket | `count_min_rejects_too_many_rows` | More rows than the seed list has seeds is refused on both sides. | Verifies a sketch past `MATRIX_MAX_ROWS` fails to serialize, that a crafted envelope of that geometry fails to decode with a message naming `MATRIX_MAX_ROWS`, and that the boundary row count still serializes. | | `estimate_does_not_clamp_i64_counts_above_i32_max` | An `i64` counter's estimate is not clamped at `i32::MAX`. | Inserts `Str("pkt_len")` with weight `35_000_000_000` into a `3x64` `CountMin, RegularPath>` through `insert_many` and verifies `estimate` is at or above that count and is not `i32::MAX as i64`. | | `merge_requires_matching_dimensions` | Merging across row counts panics. | Verifies merging a `3x32` `CountMin, RegularPath>` into a `2x32` one panics with "dimension mismatch while merging CountMin sketches". | +| `decode_rejects_each_payload_rule` | A `0x02 0x80` payload breaking any delta rule fails to decode. | For a `2x3` `i64` delta, verifies `[[0, 5], [1, -1]]` decodes and that unequal lengths, a repeated index, index `6`, and a zero delta each fail. | +| `decode_rejects_another_kind` | A delta decodes only under its own kind_id. | Verifies the `cms_i64_regular_2x3_delta` golden decodes and fails with its kind_id relabelled `0x02 0x00`. | +| `new_rejects_a_geometry_past_the_seed_list` | A delta has the state's wire geometry. | Verifies `new` accepts `20` rows and refuses `21`. | +| `merge_delta_rejects_without_changing_the_state` | A rejected delta leaves the sketch unchanged. | Verifies a `2x3` delta fails on a `3x2` sketch, that a delta whose last cell overflows `i32::MAX` fails with its first cell unadded, and that an unfilled matrix fails rather than panics. | ### Count @@ -1575,6 +1581,7 @@ Test file: [`src/message_pack_format/envelope.rs`](../src/message_pack_format/en | `rejects_bad_magic_and_version` | A wrong magic byte and an unknown version both fail. | Verifies `split` accepts `encode(&[0x01, 0x01], b"m", b"p")`, then rejects the same bytes with byte `0` set to `b'X'`, and rejects them with the version byte `6` set to `0xFF`. | | `rejects_truncation` | A truncated envelope fails rather than indexing past its slices. | Verifies `split` rejects `encode(&[0x01, 0x01], b"metadata", b"payload")` cut by its last byte, and rejects it cut to its first three bytes. | + ## Integration Suites One subsection per integration test binary under [`tests/`](../tests/); the component sections above cover the unit tests inside `src/`. A suite that spans several sketches is split by the concern each part drives, following the source file's own structure. diff --git a/src/message_pack_format/delta.rs b/src/message_pack_format/delta.rs new file mode 100644 index 00000000..07f2929e --- /dev/null +++ b/src/message_pack_format/delta.rs @@ -0,0 +1,87 @@ +//! The rules every ASAPv1 delta kind shares (`docs/asapv1_wire_format.md` +//! §3.23): the index and value checks, and the all-or-nothing add of a matrix +//! delta. + +use std::fmt; +use std::ops::RangeBounds; + +/// A counter a delta adds to. An integer add that overflows has no result; a +/// float one always has. +pub trait DeltaCounter: Copy + PartialEq + Default { + /// `self + delta`. + fn checked_add(self, delta: Self) -> Option; +} + +macro_rules! integer_counter { + ($($t:ty),*) => {$( + impl DeltaCounter for $t { + fn checked_add(self, delta: Self) -> Option { + <$t>::checked_add(self, delta) + } + } + )*}; +} +integer_counter!(i32, i64); + +impl DeltaCounter for f64 { + fn checked_add(self, delta: Self) -> Option { + Some(self + delta) + } +} + +/// Checks one index array and its value array: equal lengths, indices strictly +/// ascending and inside `range`, no value equal to zero. +pub(crate) fn check_cells( + indices: &[I], + values: &[V], + range: impl RangeBounds, +) -> Result<(), String> +where + I: Copy + Ord + fmt::Display, + V: PartialEq + Default, +{ + if indices.len() != values.len() { + return Err(format!( + "{} indices but {} values", + indices.len(), + values.len() + )); + } + if let Some(pair) = indices.windows(2).find(|pair| pair[0] >= pair[1]) { + return Err(format!( + "index {} follows {}; indices must be strictly ascending", + pair[1], pair[0] + )); + } + if let Some(index) = indices.iter().find(|index| !range.contains(index)) { + return Err(format!("index {index} is out of range")); + } + if values.iter().any(|value| *value == V::default()) { + return Err("a value is zero".to_string()); + } + Ok(()) +} + +/// Adds `deltas[i]` to `cells[indices[i]]` for every `i`. When an index is past +/// the end of `cells` or an add overflows, no cell changes. +pub(crate) fn add_cells( + cells: &mut [C], + indices: &[u64], + deltas: &[C], +) -> Result<(), String> { + let mut sums = Vec::with_capacity(indices.len()); + for (&index, &delta) in indices.iter().zip(deltas) { + let cell = usize::try_from(index) + .ok() + .and_then(|at| cells.get(at)) + .ok_or_else(|| format!("index {index} is past the {} cells", cells.len()))?; + let sum = cell + .checked_add(delta) + .ok_or_else(|| format!("adding to cell {index} overflows"))?; + sums.push(sum); + } + for (&index, sum) in indices.iter().zip(sums) { + cells[index as usize] = sum; + } + Ok(()) +} diff --git a/src/message_pack_format/envelope.rs b/src/message_pack_format/envelope.rs index ac1891bf..0adaf4a6 100644 --- a/src/message_pack_format/envelope.rs +++ b/src/message_pack_format/envelope.rs @@ -213,6 +213,9 @@ mod tests { let classic = hex(include_str!("../../asapv1_golden/hll_classic_p12.hex")); let hip = hex(include_str!("../../asapv1_golden/hll_hip_p12.hex")); let cms = hex(include_str!("../../asapv1_golden/cms_i64_regular_2x3.hex")); + let cms_delta = hex(include_str!( + "../../asapv1_golden/cms_i64_regular_2x3_delta.hex" + )); let cs = hex(include_str!("../../asapv1_golden/cs_i64_regular_2x4.hex")); let kll = hex(include_str!("../../asapv1_golden/kll_f64_k200.hex")); @@ -291,6 +294,7 @@ mod tests { ("HLL portable classic", classic, portable), ("HLL portable HIP", hip, portable), case!("CMS", cms, Cms), + case!("CMS delta", cms_delta, CountMinDelta), case!("CS", cs, Cs), case!("KLL", kll, KLL), sketch!("KLL dynamic", kll_dynamic, KLLDynamic), diff --git a/src/message_pack_format/mod.rs b/src/message_pack_format/mod.rs index 8b485028..cb5d2a74 100644 --- a/src/message_pack_format/mod.rs +++ b/src/message_pack_format/mod.rs @@ -18,6 +18,7 @@ //! [`MessagePackCodec`] and [`Error`] are the contract both share. pub mod codec; +pub(crate) mod delta; pub(crate) mod envelope; pub mod error; pub mod native; diff --git a/src/message_pack_format/wire_key.rs b/src/message_pack_format/wire_key.rs index 407964fd..ec8fbf6c 100644 --- a/src/message_pack_format/wire_key.rs +++ b/src/message_pack_format/wire_key.rs @@ -351,6 +351,9 @@ mod tests { let sdd = &signed.serialize_to_bytes().unwrap(); let hip = &hex(include_str!("../../asapv1_golden/hll_hip_p12.hex")); let cms = &hex(include_str!("../../asapv1_golden/cms_f64_fast_2x3.hex")); + let cms_delta = &hex(include_str!( + "../../asapv1_golden/cms_f64_fast_2x3_delta.hex" + )); let kll = &kll.serialize_to_bytes().unwrap(); let heap = &heap.serialize_to_bytes().unwrap(); let ss = &space_saving.serialize_to_bytes().unwrap(); @@ -360,6 +363,7 @@ mod tests { let dd_ok = |b: &[u8]| DDSketch::deserialize_from_bytes(b).is_ok(); let hip_ok = |b: &[u8]| HyperLogLogHIPP12::deserialize_from_bytes(b).is_ok(); let cms_ok = |b: &[u8]| CmsF64::deserialize_from_bytes(b).is_ok(); + let delta_ok = |b: &[u8]| CountMinDelta::::deserialize_from_bytes(b).is_ok(); let kll_ok = |b: &[u8]| KLL::::deserialize_from_bytes(b).is_ok(); let heap_ok = |b: &[u8]| HeapF64::deserialize_from_bytes(b).is_ok(); let ss_ok = |b: &[u8]| SpaceSaving::::deserialize_from_bytes(b).is_ok(); @@ -378,6 +382,7 @@ mod tests { row("HLL hip_kxq1", hip, 2.5, F32, hip_ok), row("HLL hip_est", hip, 3.0, Int, hip_ok), row("CMS f64 count", cms, 1.5, F32, cms_ok), + row("CMS f64 delta", cms_delta, 2.5, Int, delta_ok), row("KLL f64 item", kll, 7.0, Int, kll_ok), row("CMSHeap f64 count", heap, 1.0, Int, heap_ok), row("CMSHeap f32 key", heap, 1.5, F64, heap_ok), diff --git a/src/sketches/countminsketch.rs b/src/sketches/countminsketch.rs index eae02952..73b4f51e 100644 --- a/src/sketches/countminsketch.rs +++ b/src/sketches/countminsketch.rs @@ -20,7 +20,9 @@ use crate::{ QuickMatrixI128, RegularPath, SketchHasher, Vector2D, hash64_seeded, nitro_delta_saturated_i32, }; +mod delta; mod wire; +pub use delta::CountMinDelta; pub(crate) use wire::{CmsWireCounter, CmsWireMode}; const DEFAULT_ROW_NUM: usize = 3; diff --git a/src/sketches/countminsketch/delta.rs b/src/sketches/countminsketch/delta.rs new file mode 100644 index 00000000..3be4133c --- /dev/null +++ b/src/sketches/countminsketch/delta.rs @@ -0,0 +1,189 @@ +//! The Count-Min delta, ASAPv1 kind `0x02 0x80` (`docs/asapv1_wire_format.md` +//! §3.23). Its metadata is the state's (§3.2); its payload is +//! `[indices, deltas]`. + +use std::marker::PhantomData; + +use rmp_serde::{decode::Error as RmpDecodeError, encode::Error as RmpEncodeError}; +use serde::{Deserialize, Serialize}; + +use crate::message_pack_format::delta::{add_cells, check_cells}; +use crate::message_pack_format::envelope; +use crate::message_pack_format::wire_key::exact_vec; +use crate::{DefaultXxHasher, HashProfile, RegularPath, SketchHasher, Vector2D}; + +use super::CountMin; +use super::wire::{CmsWireCounter, CmsWireMode, check_geometry, decode_metadata, encode_metadata}; + +/// Count-Min delta kind_id: the state's `0x02 0x00` with the variant's top bit set. +const CMS_DELTA_KIND: &[u8] = &[0x02, 0x80]; + +/// A change to a `rows x cols` [`CountMin`] state: each value of `deltas` is +/// added to the cell its position names in `indices`, a row-major index +/// `row * cols + col`. Indices are strictly ascending and below `rows * cols`, +/// and no delta is zero. +#[derive(Clone, Debug)] +pub struct CountMinDelta { + rows: usize, + cols: usize, + indices: Vec, + deltas: Vec, + _config: PhantomData<(Mode, H)>, +} + +/// The delta payload: `[indices, deltas]`. +#[derive(Serialize, Deserialize)] +struct CmsDeltaPayload { + indices: Vec, + #[serde( + deserialize_with = "exact_vec", + bound(deserialize = "T: Deserialize<'de>") + )] + deltas: Vec, +} + +impl CountMinDelta +where + T: CmsWireCounter + Serialize + for<'de> Deserialize<'de>, + Mode: CmsWireMode, + H: SketchHasher + HashProfile, +{ + /// Builds a delta over a `rows x cols` matrix from the changed cells. Fails + /// when the geometry is not wire-eligible or the cells break the rules above. + pub fn new( + rows: usize, + cols: usize, + indices: Vec, + deltas: Vec, + ) -> Result { + check_geometry(rows, cols)?; + check_cells(&indices, &deltas, ..rows as u64 * cols as u64) + .map_err(|e| format!("CMS delta: {e}"))?; + Ok(Self { + rows, + cols, + indices, + deltas, + _config: PhantomData, + }) + } + + /// Serializes the delta into an ASAPv1 MessagePack envelope. + pub fn serialize_to_bytes(&self) -> Result, RmpEncodeError> { + let metadata = encode_metadata::(self.rows, self.cols)?; + let payload = rmp_serde::to_vec(&CmsDeltaPayload { + indices: self.indices.clone(), + deltas: self.deltas.clone(), + })?; + Ok(envelope::encode(CMS_DELTA_KIND, &metadata, &payload)) + } + + /// Deserializes a delta from an ASAPv1 MessagePack envelope. + pub fn deserialize_from_bytes(bytes: &[u8]) -> Result { + let (kind_id, metadata, payload) = + envelope::split(bytes).map_err(RmpDecodeError::Uncategorized)?; + if kind_id != CMS_DELTA_KIND { + return Err(RmpDecodeError::Uncategorized(format!( + "CMS delta kind_id mismatch: stored {kind_id:?}, expected {CMS_DELTA_KIND:?}" + ))); + } + let (rows, cols) = decode_metadata::(metadata)?; + let p: CmsDeltaPayload = envelope::decode_block(payload)?; + Self::new(rows, cols, p.indices, p.deltas).map_err(RmpDecodeError::Uncategorized) + } +} + +impl CountMin, Mode, H> +where + T: CmsWireCounter + std::ops::AddAssign + Serialize + for<'de> Deserialize<'de>, + Mode: CmsWireMode, + H: SketchHasher + HashProfile, +{ + /// Adds a delta to the counters. Fails, changing nothing, when the delta is + /// for another geometry or an integer add overflows. + pub fn merge_delta(&mut self, delta: &CountMinDelta) -> Result<(), String> { + if (self.rows(), self.cols()) != (delta.rows, delta.cols) { + return Err(format!( + "CMS delta: metadata mismatch: a {}x{} delta for a {}x{} sketch", + delta.rows, + delta.cols, + self.rows(), + self.cols() + )); + } + add_cells(self.counts.as_mut_slice(), &delta.indices, &delta.deltas) + .map_err(|e| format!("CMS delta: {e}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message_pack_format::envelope::hex; + + type Delta = CountMinDelta; + + /// A 2x3 `i64` delta envelope around the given payload arrays. + fn delta_bytes(indices: &[u64], deltas: &[i64]) -> Vec { + let metadata = encode_metadata::(2, 3).unwrap(); + let payload = rmp_serde::to_vec(&(indices, deltas)).unwrap(); + envelope::encode(CMS_DELTA_KIND, &metadata, &payload) + } + + #[test] + fn decode_rejects_each_payload_rule() { + assert!(Delta::deserialize_from_bytes(&delta_bytes(&[0, 5], &[1, -1])).is_ok()); + let rows: [(&str, &[u64], &[i64]); 4] = [ + ("unequal lengths", &[0, 1], &[1]), + ("repeated index", &[1, 1], &[1, 1]), + ("index rows * cols", &[6], &[1]), + ("zero delta", &[0], &[0]), + ]; + for (label, indices, deltas) in rows { + assert!( + Delta::deserialize_from_bytes(&delta_bytes(indices, deltas)).is_err(), + "{label}" + ); + } + } + + #[test] + fn decode_rejects_another_kind() { + let delta = hex(include_str!( + "../../../asapv1_golden/cms_i64_regular_2x3_delta.hex" + )); + assert!(Delta::deserialize_from_bytes(&delta).is_ok()); + let mut state_kind = delta.clone(); + state_kind[9] = 0x00; + assert!(Delta::deserialize_from_bytes(&state_kind).is_err()); + } + + #[test] + fn new_rejects_a_geometry_past_the_seed_list() { + assert!(Delta::new(20, 3, vec![0], vec![1]).is_ok()); + assert!(Delta::new(21, 3, vec![0], vec![1]).is_err()); + } + + #[test] + fn merge_delta_rejects_without_changing_the_state() { + let delta = |indices: Vec, deltas: Vec| { + CountMinDelta::::new(2, 3, indices, deltas).unwrap() + }; + let mut transposed = CountMin::, RegularPath>::with_dimensions(3, 2); + assert!(transposed.merge_delta(&delta(vec![0], vec![1])).is_err()); + assert_eq!(transposed.as_storage().as_slice(), &[0; 6]); + + let cells = |_, c| if c == 2 { i32::MAX } else { 0 }; + let mut full = + CountMin::, RegularPath>::from_storage(Vector2D::from_fn(2, 3, cells)); + assert!(full.merge_delta(&delta(vec![0, 5], vec![1, 1])).is_err()); + assert_eq!( + full.as_storage().as_slice(), + &[0, 0, i32::MAX, 0, 0, i32::MAX] + ); + + let mut unfilled = + CountMin::, RegularPath>::from_storage(Vector2D::init(2, 3)); + assert!(unfilled.merge_delta(&delta(vec![0], vec![1])).is_err()); + } +} diff --git a/src/sketches/countminsketch/wire.rs b/src/sketches/countminsketch/wire.rs index 39bef778..90ae1330 100644 --- a/src/sketches/countminsketch/wire.rs +++ b/src/sketches/countminsketch/wire.rs @@ -1,12 +1,12 @@ //! ASAPv1 wire serialization for the Count-Min sketch. //! -//! Child submodule of [`crate::sketches::countminsketch`]: it holds ALL of -//! Count-Min's serialization (the metadata/payload DTOs, the kind_id constant, -//! the [`CmsWireCounter`] / [`CmsWireMode`] marker traits, and the +//! Child submodule of [`crate::sketches::countminsketch`]: it holds Count-Min's +//! state serialization (the metadata/payload DTOs, the kind_id constant, the +//! [`CmsWireCounter`] / [`CmsWireMode`] marker traits, and the //! `serialize_to_bytes` / `deserialize_from_bytes` impls) while the algorithm -//! lives in the parent module file. Being a descendant module, it reads the -//! sketch's private `counts` field directly without widening any field -//! visibility. See `docs/asapv1_wire_format.md` §3.2. +//! lives in the parent module file and the delta kind in `delta.rs`. Being a +//! descendant module, it reads the sketch's private `counts` field directly +//! without widening any field visibility. See `docs/asapv1_wire_format.md` §3.2. //! //! Count-Min is one algorithm — a single kind_id `0x02 0x00`. The structural //! parameters — the matrix dimensions (`rows` / `cols`), the **counter type** @@ -21,6 +21,7 @@ use rmp_serde::{decode::Error as RmpDecodeError, encode::Error as RmpEncodeError use serde::{Deserialize, Serialize}; use crate::common::hash::check_matrix_rows; +use crate::message_pack_format::delta::DeltaCounter; use crate::message_pack_format::envelope; use crate::message_pack_format::wire_key::exact_vec; use crate::{FastPath, HashProfile, RegularPath, SketchHasher, Vector2D}; @@ -32,7 +33,7 @@ const CMS_KIND: &[u8] = &[0x02, 0x00]; /// Names the wire counter type carried in the metadata (`counter_type`). /// Implemented only for the wire-eligible counter types. -pub trait CmsWireCounter: Copy { +pub trait CmsWireCounter: DeltaCounter { /// Metadata `counter_type` string — `"i32"`, `"i64"` or `"f64"`. const COUNTER_TYPE: &'static str; } @@ -105,6 +106,54 @@ fn cms_metadata( } } +/// The wire-eligible geometry: `1 <= rows <= MATRIX_MAX_ROWS` and +/// `1 <= cols <= u32::MAX`. +pub(super) fn check_geometry(rows: usize, cols: usize) -> Result<(), String> { + if rows == 0 || cols == 0 { + return Err(format!( + "CMS dimensions must be non-zero: rows={rows}, cols={cols}" + )); + } + check_matrix_rows("CMS", rows)?; + if u32::try_from(cols).is_err() { + return Err(format!("CMS cols {cols} exceeds u32")); + } + Ok(()) +} + +/// The metadata block of a `rows x cols` sketch of this configuration. +pub(super) fn encode_metadata( + rows: usize, + cols: usize, +) -> Result, RmpEncodeError> { + check_geometry(rows, cols) + .map_err(|e| RmpEncodeError::Syntax(format!("ASAPv1 envelope: {e}")))?; + rmp_serde::to_vec_named(&cms_metadata::( + rows as u32, + cols as u32, + T::COUNTER_TYPE, + Mode::MODE, + )) +} + +/// Reads a metadata block and returns its `(rows, cols)`. The hash spec, +/// `counter_type` and `mode` must be this configuration's; `rows` / `cols` are +/// echoed back and must be a wire-eligible geometry, checked before anything is +/// sized from them. +pub(super) fn decode_metadata( + block: &[u8], +) -> Result<(usize, usize), RmpDecodeError> { + let meta: CmsMetadata = envelope::decode_block(block)?; + if meta != cms_metadata::(meta.rows, meta.cols, T::COUNTER_TYPE, Mode::MODE) { + return Err(RmpDecodeError::Uncategorized( + "ASAPv1 CMS envelope: metadata mismatch".to_string(), + )); + } + let (rows, cols) = (meta.rows as usize, meta.cols as usize); + check_geometry(rows, cols).map_err(RmpDecodeError::Uncategorized)?; + Ok((rows, cols)) +} + /// CMS payload (ASAPv1 §3.2), a msgpack **array** (`to_vec`, positional): /// `[counts]` — a 1-element array (mirroring HLL Classic's `[registers]`). The /// dimensions live in the metadata; `counts` is packed row-major and its element @@ -136,15 +185,7 @@ where let rows = self.counts.rows(); let cols = self.counts.cols(); let counts = self.counts.as_slice(); - // The decoder refuses a zero dimension, so the encoder must too: with - // `rows == 0` the length check below passes trivially (`0 == 0*cols`). - if rows == 0 || cols == 0 { - return Err(RmpEncodeError::Syntax(format!( - "ASAPv1 CMS envelope: dimensions must be non-zero: rows={rows}, cols={cols}" - ))); - } - check_matrix_rows("CMS", rows) - .map_err(|e| RmpEncodeError::Syntax(format!("ASAPv1 CMS envelope: {e}")))?; + let metadata = encode_metadata::(rows, cols)?; if counts.len() != rows.saturating_mul(cols) { return Err(RmpEncodeError::Syntax(format!( "ASAPv1 CMS envelope: counts length {} != rows*cols {}", @@ -152,15 +193,6 @@ where rows.saturating_mul(cols) ))); } - let cols_u32 = u32::try_from(cols).map_err(|_| { - RmpEncodeError::Syntax(format!("ASAPv1 CMS envelope: cols {cols} exceeds u32")) - })?; - let metadata = rmp_serde::to_vec_named(&cms_metadata::( - rows as u32, - cols_u32, - T::COUNTER_TYPE, - Mode::MODE, - ))?; let payload = rmp_serde::to_vec(&CmsPayload:: { counts: counts.to_vec(), })?; @@ -178,26 +210,8 @@ where "CMS kind_id mismatch: stored {kind_id:?}, expected {CMS_KIND:?}" ))); } - let meta: CmsMetadata = envelope::decode_block(metadata)?; - // Validate the hash spec + counter type + mode against this target; - // `rows`/`cols` are structural (the sketch is dynamically sized), so they - // are echoed back into the expected block rather than known a priori. - if meta != cms_metadata::(meta.rows, meta.cols, T::COUNTER_TYPE, Mode::MODE) { - return Err(RmpDecodeError::Uncategorized( - "ASAPv1 CMS envelope: metadata mismatch".to_string(), - )); - } - let (rows, cols) = (meta.rows as usize, meta.cols as usize); + let (rows, cols) = decode_metadata::(metadata)?; let p: CmsPayload = envelope::decode_block(payload)?; - // Reject zero dimensions before building the matrix: `Vector2D::from_fn` - // derives its mask via `cols.ilog2()`, which panics on `cols == 0`. Fail - // closed with an error rather than panicking on crafted bytes. - if rows == 0 || cols == 0 { - return Err(RmpDecodeError::Uncategorized(format!( - "CMS dimensions must be non-zero: rows={rows}, cols={cols}" - ))); - } - check_matrix_rows("CMS", rows).map_err(RmpDecodeError::Uncategorized)?; if p.counts.len() != rows.saturating_mul(cols) { return Err(RmpDecodeError::Uncategorized(format!( "CMS counts length {} != rows*cols {}", diff --git a/src/sketches/mod.rs b/src/sketches/mod.rs index c3dd98d6..6e04cbe6 100644 --- a/src/sketches/mod.rs +++ b/src/sketches/mod.rs @@ -74,7 +74,7 @@ pub use mode::{FastPath, RegularPath}; pub mod countminsketch; pub use crate::MatrixStorage; -pub use countminsketch::{CountMin, QUICKSTART_COL_NUM, QUICKSTART_ROW_NUM}; +pub use countminsketch::{CountMin, CountMinDelta, QUICKSTART_COL_NUM, QUICKSTART_ROW_NUM}; pub mod elastic; pub use elastic::Elastic; diff --git a/tests/asapv1_golden.rs b/tests/asapv1_golden.rs index 933d112b..0ec8b718 100644 --- a/tests/asapv1_golden.rs +++ b/tests/asapv1_golden.rs @@ -9,8 +9,8 @@ //! See `asapv1_golden/README.md`. use asap_sketchlib::{ - Classic, Count, CountMin, ErtlMLE, FastPath, HllSketch, HllVariant, HyperLogLogHIPP12, - HyperLogLogP12, KLL, MessagePackCodec, RegularPath, Vector2D, peek_kind_id, + Classic, Count, CountMin, CountMinDelta, ErtlMLE, FastPath, HllSketch, HllVariant, + HyperLogLogHIPP12, HyperLogLogP12, KLL, MessagePackCodec, RegularPath, Vector2D, peek_kind_id, }; fn decode_hex(s: &str) -> Vec { @@ -27,6 +27,8 @@ const GOLDEN_ERTL: &str = include_str!("../asapv1_golden/hll_ertl_mle_p12.hex"); const GOLDEN_HIP: &str = include_str!("../asapv1_golden/hll_hip_p12.hex"); const GOLDEN_CMS_I64: &str = include_str!("../asapv1_golden/cms_i64_regular_2x3.hex"); const GOLDEN_CMS_F64: &str = include_str!("../asapv1_golden/cms_f64_fast_2x3.hex"); +const GOLDEN_CMS_I64_DELTA: &str = include_str!("../asapv1_golden/cms_i64_regular_2x3_delta.hex"); +const GOLDEN_CMS_F64_DELTA: &str = include_str!("../asapv1_golden/cms_f64_fast_2x3_delta.hex"); const GOLDEN_CS_REGULAR: &str = include_str!("../asapv1_golden/cs_i64_regular_2x4.hex"); const GOLDEN_CS_FAST: &str = include_str!("../asapv1_golden/cs_i64_fast_2x4.hex"); const GOLDEN_CS_I32: &str = include_str!("../asapv1_golden/cs_i32_regular_2x4.hex"); @@ -162,6 +164,47 @@ fn cms_f64_fast_2x3_matches_golden() { assert_eq!(decoded.serialize_to_bytes().expect("re-serialize"), want); } +// --------------------------------------------------------------------------- +// Count-Min delta: build known delta -> serialize == golden, round-trips, and +// merged into the matching state golden gives the expected state. +// --------------------------------------------------------------------------- + +#[test] +fn cms_i64_regular_2x3_delta_matches_golden() { + type Cms = CountMin, RegularPath>; + let want = decode_hex(GOLDEN_CMS_I64_DELTA); + let indices = vec![0, 1, 2, 4, 5]; + let deltas = vec![127, -1, -33, -32769, 4294967296]; + let delta = CountMinDelta::::new(2, 3, indices, deltas).expect("valid"); + assert_eq!(delta.serialize_to_bytes().expect("serialize"), want); + + let decoded = CountMinDelta::::deserialize_from_bytes(&want).expect("decode"); + assert_eq!(decoded.serialize_to_bytes().expect("re-serialize"), want); + + let mut state = Cms::deserialize_from_bytes(&decode_hex(GOLDEN_CMS_I64)).expect("state"); + state.merge_delta(&decoded).expect("merge"); + let expected = [127, 0, 94, 128, -32469, 4295032832]; + assert_eq!(state.as_storage().as_slice(), expected.as_slice()); +} + +#[test] +fn cms_f64_fast_2x3_delta_matches_golden() { + type Cms = CountMin, FastPath>; + let want = decode_hex(GOLDEN_CMS_F64_DELTA); + let indices = vec![0, 3, 5]; + let deltas = vec![2.5, -0.75, -5.0625]; + let delta = CountMinDelta::::new(2, 3, indices, deltas).expect("valid"); + assert_eq!(delta.serialize_to_bytes().expect("serialize"), want); + + let decoded = CountMinDelta::::deserialize_from_bytes(&want).expect("decode"); + assert_eq!(decoded.serialize_to_bytes().expect("re-serialize"), want); + + let mut state = Cms::deserialize_from_bytes(&decode_hex(GOLDEN_CMS_F64)).expect("state"); + state.merge_delta(&decoded).expect("merge"); + let expected = [2.5, 1.5, 2.25, 3.0, 4.125, 0.0]; + assert_eq!(state.as_storage().as_slice(), expected.as_slice()); +} + // --------------------------------------------------------------------------- // Count Sketch: build known matrix state -> serialize == golden, round-trips. // All three fixtures hold the SAME matrix: the i64 pair differs only by `mode` From b82a00a48edf399a1813aa1c9261b541fb57fbfd Mon Sep 17 00:00:00 2001 From: GordonYuanyc Date: Fri, 16 Oct 2026 06:11:34 +0000 Subject: [PATCH 2/7] feat(countsketch): f64 counters and the ASAPv1 Count Sketch delta (04 80) Count Sketch takes counter_type "f64"; estimate orders rows with total_cmp. CountSketchDelta carries changed cells under the state's metadata and Count::merge_delta applies one all or nothing. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + asapv1_golden | 2 +- docs/api/api_count_sketch.md | 31 +++- docs/api/api_cs_heap.md | 10 +- docs/asapv1_wire_format.md | 4 +- docs/tests.md | 14 +- src/message_pack_format/envelope.rs | 4 + src/message_pack_format/native/countsketch.rs | 8 +- src/message_pack_format/wire_key.rs | 10 + src/sketches/countsketch.rs | 25 ++- src/sketches/countsketch/delta.rs | 174 ++++++++++++++++++ src/sketches/countsketch/wire.rs | 129 +++++++------ src/sketches/countsketch_topk/wire.rs | 7 +- src/sketches/mod.rs | 2 +- tests/asapv1_golden.rs | 67 ++++++- 15 files changed, 407 insertions(+), 84 deletions(-) create mode 100644 src/sketches/countsketch/delta.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 60808a43..36fcc958 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,10 @@ signals a backwards-compatible change. - **`CountMinDelta`**, the ASAPv1 Count-Min delta (`0x02 0x80`): built with `new` from changed cells, serialized like a sketch, and added with `CountMin::merge_delta`, which changes nothing when it fails. +- **`sketches::CountSketchDelta`**, the ASAPv1 Count Sketch delta (`0x04 0x80`), + and `f64` Count Sketch counters (`counter_type` `"f64"`): the delta is built + with `new` from changed cells, serialized like a sketch, and added with + `Count::merge_delta`, which changes nothing when it fails. ### Changed diff --git a/asapv1_golden b/asapv1_golden index 809fee5e..b20764b6 160000 --- a/asapv1_golden +++ b/asapv1_golden @@ -1 +1 @@ -Subproject commit 809fee5e26e7319ff8b0acc5ccf19c647c80fbfe +Subproject commit b20764b6352245f714c6bc71d05d9a56ea0434d6 diff --git a/docs/api/api_count_sketch.md b/docs/api/api_count_sketch.md index 8fec79ef..9413bbad 100644 --- a/docs/api/api_count_sketch.md +++ b/docs/api/api_count_sketch.md @@ -68,11 +68,10 @@ fn deserialize_from_bytes(bytes: &[u8]) -> Result These produce/consume the **ASAPv1** wire envelope (kind `0x04 0x00`) — see the [ASAPv1 wire format spec](../asapv1_wire_format.md). They are **not** available on every `Count`: the impl exists only for wire-eligible configs -`Count, Mode, H>` where `T` is `i32` or `i64` (`CsWireCounter`), -`Mode` is `FastPath` or `RegularPath` (`CsWireMode`), and `H: HashProfile`. -Count Sketch counters must be signed and negatable, so there is no `f64` -counterpart to Count-Min's; an `i128` or non-`Vector2D` sketch must be converted -first (only you know if the mapping is lossless). +`Count, Mode, H>` where `T` is `i32`, `i64` or `f64` +(`CsWireCounter`), `Mode` is `FastPath` or `RegularPath` (`CsWireMode`), and +`H: HashProfile`. An `i128` or non-`Vector2D` sketch must be converted first +(only you know if the mapping is lossless). `i32` is **not** widened to `i64` on the wire. The counter type is carried in the metadata and pinned on decode, so `i32` bytes do not decode into an `i64` @@ -95,6 +94,28 @@ accumulator per row. `l2` is carried rather than recomputed, because accumulator saturates one way. The same `(counts, l2)` pair is what a `UnivMon` layer inlines into its own payload. +## Deltas + +```rust +CountSketchDelta + +fn new(rows: usize, cols: usize, indices: Vec, deltas: Vec) -> Result +fn serialize_to_bytes(&self) -> Result, RmpEncodeError> +fn deserialize_from_bytes(bytes: &[u8]) -> Result + +// on Count, Mode, H> +fn merge_delta(&mut self, delta: &CountSketchDelta) -> Result<(), String> +``` + +A `CountSketchDelta` is the ASAPv1 delta kind `0x04 0x80`, for the same configs +as the state serialization: the changed cells as strictly ascending row-major +indices `row * cols + col`, below `rows * cols`, and the non-zero signed value +added to each. `new` and the decoder refuse anything else. `merge_delta` adds +the delta to the counters; a delta of other dimensions, or an integer add that +overflows, fails and leaves the sketch unchanged. The crate root's +`CountSketchDelta` is the legacy portable type; this one is +`asap_sketchlib::sketches::CountSketchDelta`. + ## Examples ```rust diff --git a/docs/api/api_cs_heap.md b/docs/api/api_cs_heap.md index 4e133fbd..f39e9b30 100644 --- a/docs/api/api_cs_heap.md +++ b/docs/api/api_cs_heap.md @@ -53,11 +53,11 @@ fn deserialize_from_bytes(bytes: &[u8]) -> Result These produce/consume the **ASAPv1** wire envelope (kind `0x0a 0x00`) — see the [ASAPv1 wire format spec](../asapv1_wire_format.md). They are **not** available on every `CSHeap`: the impl exists only for wire-eligible configs -`CSHeap, Mode, H>` where `T` is `i32` or `i64` (`CsWireCounter`), -`Mode` is `FastPath` or `RegularPath` (`CsWireMode`), and `H: HashProfile`. Count Sketch -counters must be signed and negatable, so there is no `f64` counterpart to -Count-Min's; an `i128` or non-`Vector2D` sketch must be converted first (only -you know if the mapping is lossless). +`CSHeap, Mode, H>` where `T` is `i32`, `i64` or `f64` +(`CsWireCounter`), `Mode` is `FastPath` or `RegularPath` (`CsWireMode`), and +`H: HashProfile`. Heap counts are written as `i64` under every counter type. An +`i128` or non-`Vector2D` sketch must be converted first (only you know if the +mapping is lossless). A sketch travels as the base matrix plus the heap's entries: the metadata carries `rows`, `cols`, `counter_type`, `mode`, the heap capacity `k` and the diff --git a/docs/asapv1_wire_format.md b/docs/asapv1_wire_format.md index 64a29cd8..c024ed4d 100644 --- a/docs/asapv1_wire_format.md +++ b/docs/asapv1_wire_format.md @@ -26,7 +26,7 @@ If the doc feels long, these sections carry the key points: ## Status -- **Implemented (Rust).** Every kind_id the Section 1 registry marks *implemented* serializes through the shared `message_pack_format::envelope` module per this spec: HLL (three estimators), Count-Min with its delta (`CountMinDelta`), CMSHeap, Count Sketch, CSHeap, DDSketch, KLL (compact + dynamic), Hydra (five counter variants), Elastic, Coco, UniformSampling, KMV, UnivMon, UnivMon Optimized, ExponentialHistogram, EHSketchList, Bloom, Space-Saving, CountL2HH and UnivMon-Q. +- **Implemented (Rust).** Every kind_id the Section 1 registry marks *implemented* serializes through the shared `message_pack_format::envelope` module per this spec: HLL (three estimators), Count-Min with its delta (`CountMinDelta`), CMSHeap, Count Sketch with its delta (`sketches::CountSketchDelta`), CSHeap, DDSketch, KLL (compact + dynamic), Hydra (five counter variants), Elastic, Coco, UniformSampling, KMV, UnivMon, UnivMon Optimized, ExponentialHistogram, EHSketchList, Bloom, Space-Saving, CountL2HH and UnivMon-Q. - **Self-describing.** The hash-spec metadata is derived from the hasher's `HashProfile` (read live, never hardcoded), so the bytes truthfully describe how a sketch was hashed; custom hash profiles are supported (Section 2). - **Byte-level encoding** is pinned in Section 4; the resolved decisions are summarized at the end. - **`sketchlib-go`** is aligned separately (see Cross-language contract). @@ -193,7 +193,7 @@ This registry is the master list of algorithms still to design payloads for, and | `0x03 0x00` | Count-Min-with-heap (CMSHeap) | Count-Min + top-k heap | Section 3.7 | implemented | | `0x03 0x80` | Count-Min-with-heap (CMSHeap) | CMSHeap delta | Section 3.23 | designed | | `0x04 0x00` | Count Sketch | Count Sketch | Section 3.6 | implemented | -| `0x04 0x80` | Count Sketch | Count Sketch delta | Section 3.23 | designed | +| `0x04 0x80` | Count Sketch | Count Sketch delta | Section 3.23 | implemented | | `0x05 0x00` | DDSketch | Logarithmic bucket stores | Section 3.8 | implemented | | `0x05 0x80` | DDSketch | DDSketch delta | Section 3.23 | designed | | `0x06 0x00` | KLL | Compact | Section 3.3 | implemented | diff --git a/docs/tests.md b/docs/tests.md index fdda276c..79a55c17 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -63,6 +63,8 @@ Test file: [`src/sketches/countsketch.rs`](../src/sketches/countsketch.rs) Wire tests: [`src/sketches/countsketch/wire.rs`](../src/sketches/countsketch/wire.rs) +Delta tests: [`src/sketches/countsketch/delta.rs`](../src/sketches/countsketch/delta.rs) + | test_name | test_description | what_is_tested | | --- | --- | --- | | `default_initializes_expected_dimensions` | Default dimensions initialize zeroed counters. | Verifies default `Count` dimensions (`rows=3`, `cols=4096`) and that all counters are zero after construction. | @@ -88,10 +90,15 @@ Wire tests: [`src/sketches/countsketch/wire.rs`](../src/sketches/countsketch/wir | `count_sketch_rejects_too_many_rows` | More rows than the seed list has seeds is refused on both sides. | Verifies a sketch past `MATRIX_MAX_ROWS` fails to serialize, that a crafted envelope of that geometry fails to decode with a message naming `MATRIX_MAX_ROWS`, and that the boundary row count still serializes. | | `cs_metadata_rejects_unknown_keys` | An unexpected metadata key fails closed. | Encodes the eleven `CsMetadata` fields plus a `bogus_field` as a named map and verifies it does not decode as `CsMetadata`. | | `cs_metadata_rejects_a_missing_counter_type_key` | `counter_type` is required and can never be silently defaulted. | Encodes the other ten `CsMetadata` fields as a named map with `counter_type` omitted and verifies it does not decode as `CsMetadata`. | -| `cs_metadata_rejects_a_foreign_counter_type_name` | A Count-Min-only counter type name is not a Count Sketch one. | Wraps metadata naming `counter_type: "f64"` around a valid 2x4 payload and verifies both the `i64` and the `i32` sketch reject it. | +| `cs_metadata_rejects_f64_into_an_integer_sketch` | An `f64`-labelled envelope decodes into neither integer sketch. | Wraps metadata naming `counter_type: "f64"` around a valid 2x4 payload and verifies both the `i64` and the `i32` sketch reject it. | | `count_sketch_i32_round_trips_and_is_pinned_by_counter_type` | The `i32` wire config round-trips and its width is identity, not a detail. | Round-trips a `2x4` `Vector2D` sketch, then verifies its bytes differ from the numerically equal `i64` sketch's, that `i32` bytes fail to decode as `i64`, and that `i64` bytes fail to decode as `i32`. | | `countsketch_error_stays_rank_independent_across_frequency_deciles` | Mean absolute error does not track key frequency. | On a Zipf stream (`rows=5`, `cols=4096`, `domain=8192`, `exponent=1.1`, `N=200_000`, seed `1007`) into a `Count, RegularPath>`, sorts the keys by true count into 10 equal deciles and verifies the largest decile mean `\|estimate - truth\|` is at most `3x` the smallest; a spread up to that 3x band passes. | | `merge_requires_matching_dimensions` | Merging across row counts panics. | Verifies merging a `3x32` `Count, RegularPath>` into a `2x32` one panics with "dimension mismatch while merging CountMin sketches". | +| `f64_estimate_is_the_unrounded_median_and_takes_nan` | An `f64` sketch's estimate is not rounded, and a `NaN` cell does not panic. | Verifies `insert_many(Str("alpha"), 1.5)` into a `3x64` `Count, RegularPath>` estimates `1.5`, and that a `3x4` matrix of `NaN` estimates `NaN`. | +| `decode_rejects_an_index_past_the_matrix` | A `0x04 0x80` index must be below `rows * cols`. | For a `2x4` `i64` delta, verifies index `7` decodes and index `8` fails. | +| `decode_rejects_another_kind` | A delta decodes only under its own kind_id. | Verifies the `cs_i64_regular_2x4_delta` golden decodes and fails with its kind_id relabelled `0x04 0x00`. | +| `new_rejects_a_geometry_past_the_seed_list` | A delta has the state's wire geometry. | Verifies `new` accepts `20` rows and refuses `21`. | +| `merge_delta_rejects_without_changing_the_state` | A rejected delta leaves the sketch unchanged. | Verifies a `2x4` delta fails on a `4x2` sketch, and that a delta whose last add goes below `i32::MIN` fails with its first cell unadded. | ### HyperLogLog @@ -320,7 +327,7 @@ Wire tests: [`src/sketches/countsketch_topk/wire.rs`](../src/sketches/countsketc | `cs_heap_rejects_serializing_an_unfilled_matrix` | The encode side refuses a matrix its own decoder would reject. | Verifies `Vector2D::::init(2, 4)`, which reserves eight cells without filling them, fails to serialize. | | `cs_heap_metadata_rejects_unknown_keys` | An unexpected metadata key fails closed. | Encodes the thirteen `TopKMetadata` fields plus a `bogus_field` as a named map and verifies it does not decode as `TopKMetadata`. | | `cs_heap_metadata_rejects_a_missing_key_type_key` | `key_type` is required, so the heap's key variant can never be silently defaulted. | Encodes the other twelve `TopKMetadata` fields as a named map with `key_type` omitted and verifies it does not decode as `TopKMetadata`. | -| `cs_heap_rejects_a_foreign_counter_type_name` | A Count-Min-only counter type name is not a Count Sketch one. | Wraps metadata naming `counter_type: "f64"` around a valid `2x4` payload and verifies both the `i64` and the `i32` sketch reject it. | +| `cs_heap_rejects_f64_into_an_integer_sketch` | An `f64`-labelled envelope decodes into neither integer sketch. | Wraps metadata naming `counter_type: "f64"` around a valid `2x4` payload and verifies both the `i64` and the `i32` sketch reject it. | | `cs_heap_custom_hasher_profile_round_trips_and_is_self_describing` | The metadata describes the hasher that built the sketch rather than a hardcoded profile. | For a `3x8` sketch with a heap of `4` over a hasher declaring its own `HashProfile`, verifies the counters and both heap entries round-trip, the bytes differ from the standard-profile sketch's over the same keys, and a standard-profile decode rejects them. | | `cs_heap_refuses_a_k_the_metadata_cannot_carry` | A `k` past the metadata's `u32` field fails the encode rather than truncating. | Verifies a `2x4` sketch built at a heap capacity of `1 << 40` fails to serialize with a message naming the "exceeds the u32 metadata field" rule. | | `cs_heap_rejects_too_many_rows` | The base matrix carries the seed list's row bound. | Verifies a sketch past `MATRIX_MAX_ROWS` fails to serialize, that a crafted envelope of that geometry fails to decode with a message naming `MATRIX_MAX_ROWS`, and that the boundary row count still serializes. | @@ -2136,8 +2143,11 @@ Test file: [`tests/asapv1_golden.rs`](../tests/asapv1_golden.rs) | --- | --- | --- | | `cms_f64_fast_2x3_matches_golden` | A known f64 fast-path Count-Min matrix serializes to its checked-in golden bytes. | Verifies `CountMin::, FastPath>::from_storage` over the `2x3` matrix `[[0.0, 1.5, 2.25], [3.75, 4.125, 5.0625]]` serializes to `asapv1_golden/cms_f64_fast_2x3.hex`, and that decoding that fixture recovers the six cells in row-major order and re-serializes to the same bytes. | | `cms_i64_regular_2x3_matches_golden` | A known i64 regular-path Count-Min matrix serializes to its checked-in golden bytes. | Verifies `CountMin::, RegularPath>::from_storage` over the `2x3` matrix `[[0, 1, 127], [128, 300, 65536]]` serializes to `asapv1_golden/cms_i64_regular_2x3.hex`, and that decoding the fixture reports `rows()` `2`, `cols()` `3`, the six cells in row-major order, and a byte-identical re-serialization. | +| `cs_f64_fast_2x4_delta_matches_golden` | A known f64 fast-path Count Sketch delta serializes to its golden bytes and merges into its state golden. | Verifies `CountSketchDelta::::new(2, 4, [1, 3, 5, 7], [-0.75, 2.25, 0.5, 1e300])` serializes to `asapv1_golden/cs_f64_fast_2x4_delta.hex`, that the fixture decodes and re-serializes identically, and that merged into `cs_f64_fast_2x4` it gives `[0.0, -0.75, 1.5, 0.0, 127.0, -32768.0, 0.125, 0.0]`. | +| `cs_f64_fast_2x4_matches_golden` | A known f64 Count Sketch matrix serializes to its golden bytes. | Verifies `Count::, FastPath>::from_storage` over `[[0.0, -0.0, 1.5, -2.25], [127.0, -32768.5, 0.125, -1e300]]` serializes to `asapv1_golden/cs_f64_fast_2x4.hex`, and that decoding it recovers the eight cells and re-serializes identically. | | `cs_i32_regular_2x4_matches_golden` | The i32 counter type reaches the metadata, and an i32 golden is not an i64 sketch. | Verifies `Count::, RegularPath>::from_storage` over `[[0, 127, 128, 65536], [-1, -33, -32768, -2147483648]]` cast to `i32` serializes to `asapv1_golden/cs_i32_regular_2x4.hex`, that decoding it recovers the eight cells and re-serializes identically, that the i32 and i64 regular fixtures have equal length yet differ, and that the i32 fixture fails to decode as `Count, RegularPath>`. | | `cs_i64_fast_2x4_matches_golden` | The fast-path mode reaches the bytes, so the same matrix encodes differently per path. | Verifies `Count::, FastPath>::from_storage` over `[[0, 127, 128, 65536], [-1, -33, -32768, -2147483648]]` serializes to `asapv1_golden/cs_i64_fast_2x4.hex`, that decoding it recovers the eight cells and re-serializes identically, and that the fixture differs from `cs_i64_regular_2x4.hex` built from the same matrix. | +| `cs_i64_regular_2x4_delta_matches_golden` | A known i64 Count Sketch delta serializes to its golden bytes and merges into its state golden. | Verifies `CountSketchDelta::::new(2, 4, [0, 1, 2, 4, 6, 7], [-32, -128, 255, -129, 65535, -2147483649])` serializes to `asapv1_golden/cs_i64_regular_2x4_delta.hex`, that the fixture decodes and re-serializes identically, and that merged into `cs_i64_regular_2x4` it gives `[-32, -1, 383, 65536, -130, -33, 32767, -4294967297]`. | | `cs_i64_regular_2x4_matches_golden` | A known signed Count-Sketch matrix serializes to its checked-in golden bytes. | Verifies `Count::, RegularPath>::from_storage` over `[[0, 127, 128, 65536], [-1, -33, -32768, -2147483648]]` — sweeping the msgpack integer widths in both directions — serializes to `asapv1_golden/cs_i64_regular_2x4.hex`, and that decoding the fixture reports `rows()` `2`, `cols()` `4`, the eight cells, and a byte-identical re-serialization. | | `hll_classic_p12_matches_golden` | A known P12 Classic register pattern serializes to its golden bytes on both the portable and native paths. | Builds `HllSketch::from_raw(HllVariant::Regular, 12, regs, 0.0, 0.0, 0.0)` where `regs` is 4,096 zeros with `[0] = 1`, `[1] = 7`, `[100] = 42` and `[4095] = 3`, verifies `to_msgpack()` equals `asapv1_golden/hll_classic_p12.hex`, that `HyperLogLogP12::::deserialize_from_bytes` on the fixture recovers those registers and re-serializes identically, and that `HllSketch::from_msgpack` reports the same registers, `precision` `12` and `HllVariant::Regular`. | | `hll_ertl_mle_p12_matches_golden` | The Datafusion variant tag reaches the bytes for the same P12 register pattern. | Builds `HllSketch::from_raw(HllVariant::Datafusion, 12, regs, 0.0, 0.0, 0.0)` over the shared P12 pattern (`[0] = 1`, `[1] = 7`, `[100] = 42`, `[4095] = 3` in 4,096 registers), verifies `to_msgpack()` equals `asapv1_golden/hll_ertl_mle_p12.hex`, that `HyperLogLogP12::::deserialize_from_bytes` recovers the registers and re-serializes identically, and that `HllSketch::from_msgpack` reports `HllVariant::Datafusion`. | diff --git a/src/message_pack_format/envelope.rs b/src/message_pack_format/envelope.rs index 0adaf4a6..cfe0b18d 100644 --- a/src/message_pack_format/envelope.rs +++ b/src/message_pack_format/envelope.rs @@ -217,6 +217,9 @@ mod tests { "../../asapv1_golden/cms_i64_regular_2x3_delta.hex" )); let cs = hex(include_str!("../../asapv1_golden/cs_i64_regular_2x4.hex")); + let cs_delta = hex(include_str!( + "../../asapv1_golden/cs_i64_regular_2x4_delta.hex" + )); let kll = hex(include_str!("../../asapv1_golden/kll_f64_k200.hex")); let str_keys = ["alpha", "beta", "gamma", "alpha", "alpha"].map(DataInput::Str); @@ -296,6 +299,7 @@ mod tests { case!("CMS", cms, Cms), case!("CMS delta", cms_delta, CountMinDelta), case!("CS", cs, Cs), + case!("CS delta", cs_delta, sketches::CountSketchDelta), case!("KLL", kll, KLL), sketch!("KLL dynamic", kll_dynamic, KLLDynamic), sketch!("CMSHeap", cms_heap, CmsHeap), diff --git a/src/message_pack_format/native/countsketch.rs b/src/message_pack_format/native/countsketch.rs index 89375065..7d028c45 100644 --- a/src/message_pack_format/native/countsketch.rs +++ b/src/message_pack_format/native/countsketch.rs @@ -1,10 +1,8 @@ //! Native MessagePack codec impl for [`crate::sketches::countsketch::Count`]. //! -//! Only the canonical wire configs — `i32` or `i64` counters (`CsWireCounter`) -//! with a fast/regular mode (`CsWireMode`) — are serializable. There is no `f64` -//! counterpart to Count-Min's, because `CountSketchCounter` is implemented only -//! for `i32`, `i64` and `i128`; `i128` and non-`Vector2D` storage must be -//! converted first. +//! Only the canonical wire configs — `i32`, `i64` or `f64` counters +//! (`CsWireCounter`) with a fast/regular mode (`CsWireMode`) — are +//! serializable; `i128` and non-`Vector2D` storage must be converted first. use serde::{Deserialize, Serialize}; diff --git a/src/message_pack_format/wire_key.rs b/src/message_pack_format/wire_key.rs index ec8fbf6c..68c40168 100644 --- a/src/message_pack_format/wire_key.rs +++ b/src/message_pack_format/wire_key.rs @@ -354,6 +354,10 @@ mod tests { let cms_delta = &hex(include_str!( "../../asapv1_golden/cms_f64_fast_2x3_delta.hex" )); + let cs = &hex(include_str!("../../asapv1_golden/cs_f64_fast_2x4.hex")); + let cs_delta = &hex(include_str!( + "../../asapv1_golden/cs_f64_fast_2x4_delta.hex" + )); let kll = &kll.serialize_to_bytes().unwrap(); let heap = &heap.serialize_to_bytes().unwrap(); let ss = &space_saving.serialize_to_bytes().unwrap(); @@ -364,6 +368,10 @@ mod tests { let hip_ok = |b: &[u8]| HyperLogLogHIPP12::deserialize_from_bytes(b).is_ok(); let cms_ok = |b: &[u8]| CmsF64::deserialize_from_bytes(b).is_ok(); let delta_ok = |b: &[u8]| CountMinDelta::::deserialize_from_bytes(b).is_ok(); + let cs_ok = |b: &[u8]| Count::, FastPath>::deserialize_from_bytes(b).is_ok(); + let cs_delta_ok = |b: &[u8]| { + sketches::CountSketchDelta::::deserialize_from_bytes(b).is_ok() + }; let kll_ok = |b: &[u8]| KLL::::deserialize_from_bytes(b).is_ok(); let heap_ok = |b: &[u8]| HeapF64::deserialize_from_bytes(b).is_ok(); let ss_ok = |b: &[u8]| SpaceSaving::::deserialize_from_bytes(b).is_ok(); @@ -383,6 +391,8 @@ mod tests { row("HLL hip_est", hip, 3.0, Int, hip_ok), row("CMS f64 count", cms, 1.5, F32, cms_ok), row("CMS f64 delta", cms_delta, 2.5, Int, delta_ok), + row("CS f64 count", cs, -2.25, F32, cs_ok), + row("CS f64 delta", cs_delta, 2.25, Int, cs_delta_ok), row("KLL f64 item", kll, 7.0, Int, kll_ok), row("CMSHeap f64 count", heap, 1.0, Int, heap_ok), row("CMSHeap f32 key", heap, 1.5, F64, heap_ok), diff --git a/src/sketches/countsketch.rs b/src/sketches/countsketch.rs index 6a3c7ef5..0e9fc910 100644 --- a/src/sketches/countsketch.rs +++ b/src/sketches/countsketch.rs @@ -17,7 +17,9 @@ use serde::{Deserialize, Serialize}; use std::marker::PhantomData; use std::ops::Neg; +mod delta; mod wire; +pub use delta::CountSketchDelta; pub(crate) use wire::{CsWireCounter, CsWireMode}; const DEFAULT_ROW_NUM: usize = 3; @@ -68,6 +70,13 @@ impl CountSketchCounter for i128 { } } +// Implements CountSketchCounter for f64. +impl CountSketchCounter for f64 { + fn to_f64(self) -> f64 { + self + } +} + // Default Count sketch for Vector2D (RegularPath). impl Default for Count, RegularPath> { fn default() -> Self { @@ -335,7 +344,7 @@ where if estimates.is_empty() { return 0.0; } - estimates.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap()); + estimates.sort_unstable_by(f64::total_cmp); let mid = estimates.len() / 2; if estimates.len() % 2 == 1 { estimates[mid] @@ -865,6 +874,20 @@ mod tests { } } + #[test] + fn f64_estimate_is_the_unrounded_median_and_takes_nan() { + let key = DataInput::Str("alpha"); + let mut sketch = Count::, RegularPath>::with_dimensions(3, 64); + sketch.insert_many(&key, 1.5); + assert_eq!(sketch.estimate(&key), 1.5); + + let nan = + Count::, RegularPath>::from_storage(Vector2D::from_fn(3, 4, |_, _| { + f64::NAN + })); + assert!(nan.estimate(&key).is_nan()); + } + #[test] #[should_panic(expected = "dimension mismatch while merging CountMin sketches")] fn merge_requires_matching_dimensions() { diff --git a/src/sketches/countsketch/delta.rs b/src/sketches/countsketch/delta.rs new file mode 100644 index 00000000..a53c8d0c --- /dev/null +++ b/src/sketches/countsketch/delta.rs @@ -0,0 +1,174 @@ +//! The Count Sketch delta, ASAPv1 kind `0x04 0x80` (`docs/asapv1_wire_format.md` +//! §3.23). Its metadata is the state's (§3.6); its payload is +//! `[indices, deltas]`. + +use std::marker::PhantomData; + +use rmp_serde::{decode::Error as RmpDecodeError, encode::Error as RmpEncodeError}; +use serde::{Deserialize, Serialize}; + +use crate::message_pack_format::delta::{add_cells, check_cells}; +use crate::message_pack_format::envelope; +use crate::message_pack_format::wire_key::exact_vec; +use crate::{DefaultXxHasher, HashProfile, RegularPath, SketchHasher, Vector2D}; + +use super::wire::{CsWireCounter, CsWireMode, check_geometry, decode_metadata, encode_metadata}; +use super::{Count, CountSketchCounter}; + +/// Count Sketch delta kind_id: the state's `0x04 0x00` with the variant's top bit set. +const CS_DELTA_KIND: &[u8] = &[0x04, 0x80]; + +/// A change to a `rows x cols` [`Count`] state: each value of `deltas` is added +/// to the cell its position names in `indices`, a row-major index +/// `row * cols + col`. Indices are strictly ascending and below `rows * cols`, +/// and no delta is zero. +#[derive(Clone, Debug)] +pub struct CountSketchDelta { + rows: usize, + cols: usize, + indices: Vec, + deltas: Vec, + _config: PhantomData<(Mode, H)>, +} + +/// The delta payload: `[indices, deltas]`. +#[derive(Serialize, Deserialize)] +struct CsDeltaPayload { + indices: Vec, + #[serde( + deserialize_with = "exact_vec", + bound(deserialize = "T: Deserialize<'de>") + )] + deltas: Vec, +} + +impl CountSketchDelta +where + T: CsWireCounter + Serialize + for<'de> Deserialize<'de>, + Mode: CsWireMode, + H: SketchHasher + HashProfile, +{ + /// Builds a delta over a `rows x cols` matrix from the changed cells. Fails + /// when the geometry is not wire-eligible or the cells break the rules above. + pub fn new( + rows: usize, + cols: usize, + indices: Vec, + deltas: Vec, + ) -> Result { + check_geometry(rows, cols)?; + check_cells(&indices, &deltas, ..rows as u64 * cols as u64) + .map_err(|e| format!("Count Sketch delta: {e}"))?; + Ok(Self { + rows, + cols, + indices, + deltas, + _config: PhantomData, + }) + } + + /// Serializes the delta into an ASAPv1 MessagePack envelope. + pub fn serialize_to_bytes(&self) -> Result, RmpEncodeError> { + let metadata = encode_metadata::(self.rows, self.cols)?; + let payload = rmp_serde::to_vec(&CsDeltaPayload { + indices: self.indices.clone(), + deltas: self.deltas.clone(), + })?; + Ok(envelope::encode(CS_DELTA_KIND, &metadata, &payload)) + } + + /// Deserializes a delta from an ASAPv1 MessagePack envelope. + pub fn deserialize_from_bytes(bytes: &[u8]) -> Result { + let (kind_id, metadata, payload) = + envelope::split(bytes).map_err(RmpDecodeError::Uncategorized)?; + if kind_id != CS_DELTA_KIND { + return Err(RmpDecodeError::Uncategorized(format!( + "Count Sketch delta kind_id mismatch: stored {kind_id:?}, expected {CS_DELTA_KIND:?}" + ))); + } + let (rows, cols) = decode_metadata::(metadata)?; + let p: CsDeltaPayload = envelope::decode_block(payload)?; + Self::new(rows, cols, p.indices, p.deltas).map_err(RmpDecodeError::Uncategorized) + } +} + +impl Count, Mode, H> +where + T: CsWireCounter + CountSketchCounter, + Mode: CsWireMode, + H: SketchHasher + HashProfile, +{ + /// Adds a delta to the counters. Fails, changing nothing, when the delta is + /// for another geometry or an integer add overflows. + pub fn merge_delta(&mut self, delta: &CountSketchDelta) -> Result<(), String> { + if (self.rows(), self.cols()) != (delta.rows, delta.cols) { + return Err(format!( + "Count Sketch delta: metadata mismatch: a {}x{} delta for a {}x{} sketch", + delta.rows, + delta.cols, + self.rows(), + self.cols() + )); + } + add_cells(self.counts.as_mut_slice(), &delta.indices, &delta.deltas) + .map_err(|e| format!("Count Sketch delta: {e}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message_pack_format::envelope::hex; + + type Delta = CountSketchDelta; + + /// A 2x4 `i64` delta envelope around the given payload arrays. + fn delta_bytes(indices: &[u64], deltas: &[i64]) -> Vec { + let metadata = encode_metadata::(2, 4).unwrap(); + let payload = rmp_serde::to_vec(&(indices, deltas)).unwrap(); + envelope::encode(CS_DELTA_KIND, &metadata, &payload) + } + + #[test] + fn decode_rejects_an_index_past_the_matrix() { + assert!(Delta::deserialize_from_bytes(&delta_bytes(&[7], &[-1])).is_ok()); + assert!(Delta::deserialize_from_bytes(&delta_bytes(&[8], &[-1])).is_err()); + } + + #[test] + fn decode_rejects_another_kind() { + let delta = hex(include_str!( + "../../../asapv1_golden/cs_i64_regular_2x4_delta.hex" + )); + assert!(Delta::deserialize_from_bytes(&delta).is_ok()); + let mut state_kind = delta.clone(); + state_kind[9] = 0x00; + assert!(Delta::deserialize_from_bytes(&state_kind).is_err()); + } + + #[test] + fn new_rejects_a_geometry_past_the_seed_list() { + assert!(Delta::new(20, 4, vec![0], vec![1]).is_ok()); + assert!(Delta::new(21, 4, vec![0], vec![1]).is_err()); + } + + #[test] + fn merge_delta_rejects_without_changing_the_state() { + let delta = |indices: Vec, deltas: Vec| { + CountSketchDelta::::new(2, 4, indices, deltas).unwrap() + }; + let mut transposed = Count::, RegularPath>::with_dimensions(4, 2); + assert!(transposed.merge_delta(&delta(vec![0], vec![1])).is_err()); + assert_eq!(transposed.as_storage().as_slice(), &[0; 8]); + + let cells = |_, c| if c == 3 { i32::MIN } else { 0 }; + let mut full = + Count::, RegularPath>::from_storage(Vector2D::from_fn(2, 4, cells)); + assert!(full.merge_delta(&delta(vec![0, 7], vec![-1, -1])).is_err()); + assert_eq!( + full.as_storage().as_slice(), + &[0, 0, 0, i32::MIN, 0, 0, 0, i32::MIN] + ); + } +} diff --git a/src/sketches/countsketch/wire.rs b/src/sketches/countsketch/wire.rs index 7f3e3f29..70b9e385 100644 --- a/src/sketches/countsketch/wire.rs +++ b/src/sketches/countsketch/wire.rs @@ -1,24 +1,22 @@ //! ASAPv1 wire serialization for the Count Sketch. //! -//! Child submodule of [`crate::sketches::countsketch`]: it holds ALL of Count -//! Sketch's serialization (the metadata/payload DTOs, the kind_id constant, the +//! Child submodule of [`crate::sketches::countsketch`]: it holds Count Sketch's +//! state serialization (the metadata/payload DTOs, the kind_id constant, the //! [`CsWireCounter`] / [`CsWireMode`] marker traits, and the //! `serialize_to_bytes` / `deserialize_from_bytes` impls) while the algorithm -//! lives in the parent module file. Being a descendant module, it reads the -//! sketch's private `counts` field directly without widening any field -//! visibility. See `docs/asapv1_wire_format.md` §3.6. +//! lives in the parent module file and the delta kind in `delta.rs`. Being a +//! descendant module, it reads the sketch's private `counts` field directly +//! without widening any field visibility. See `docs/asapv1_wire_format.md` §3.6. //! //! Count Sketch is one algorithm — a single kind_id `0x04 0x00`. The structural //! parameters — the matrix dimensions (`rows` / `cols`), the **counter type** -//! (i32/i64) and the column-derivation **mode** (fast/regular) — live in the +//! (i32/i64/f64) and the column-derivation **mode** (fast/regular) — live in the //! metadata, so the payload itself is just `[counts]` (a 1-element array //! mirroring Count-Min's). //! -//! Wire counter types are `i32` and `i64` — the two [`CsWireCounter`] is -//! implemented for. Count-Min's `f64` has no counterpart here because -//! [`CountSketchCounter`] is implemented only for `i32`, `i64` and `i128`, so -//! `f64` is not a Count Sketch counter at all; `i128` is a counter but has no -//! msgpack integer form. `i32` is carried at its own width rather than widened, +//! Wire counter types are `i32`, `i64` and `f64` — the three [`CsWireCounter`] +//! is implemented for. `i128` is a [`CountSketchCounter`] but has no msgpack +//! integer form. `i32` is carried at its own width rather than widened, //! because a nested Count Sketch — the `Vector2D` counters `HydraCounter` //! and `EHSketchList` hold — must decode back into the type it was stored as. //! @@ -28,7 +26,9 @@ use rmp_serde::{decode::Error as RmpDecodeError, encode::Error as RmpEncodeError use serde::{Deserialize, Serialize}; use crate::common::hash::check_matrix_rows; +use crate::message_pack_format::delta::DeltaCounter; use crate::message_pack_format::envelope; +use crate::message_pack_format::wire_key::exact_vec; use crate::{FastPath, HashProfile, RegularPath, SketchHasher, Vector2D}; use super::{Count, CountSketchCounter}; @@ -37,9 +37,9 @@ use super::{Count, CountSketchCounter}; const CS_KIND: &[u8] = &[0x04, 0x00]; /// Names the wire counter type carried in the metadata (`counter_type`). -/// Implemented only for the two wire-eligible counter types. -pub trait CsWireCounter: Copy { - /// Metadata `counter_type` string — `"i32"` or `"i64"`. +/// Implemented only for the wire-eligible counter types. +pub trait CsWireCounter: DeltaCounter { + /// Metadata `counter_type` string — `"i32"`, `"i64"` or `"f64"`. const COUNTER_TYPE: &'static str; } impl CsWireCounter for i32 { @@ -48,6 +48,9 @@ impl CsWireCounter for i32 { impl CsWireCounter for i64 { const COUNTER_TYPE: &'static str = "i64"; } +impl CsWireCounter for f64 { + const COUNTER_TYPE: &'static str = "f64"; +} /// Names the wire column-derivation mode carried in the metadata (`mode`). pub trait CsWireMode { @@ -105,6 +108,54 @@ fn cs_metadata(rows: u32, cols: u32, counter_type: &str, mode: & } } +/// The wire-eligible geometry: `1 <= rows <= MATRIX_MAX_ROWS` and +/// `1 <= cols <= u32::MAX`. +pub(super) fn check_geometry(rows: usize, cols: usize) -> Result<(), String> { + if rows == 0 || cols == 0 { + return Err(format!( + "Count Sketch dimensions must be non-zero: rows={rows}, cols={cols}" + )); + } + check_matrix_rows("Count Sketch", rows)?; + if u32::try_from(cols).is_err() { + return Err(format!("Count Sketch cols {cols} exceeds u32")); + } + Ok(()) +} + +/// The metadata block of a `rows x cols` sketch of this configuration. +pub(super) fn encode_metadata( + rows: usize, + cols: usize, +) -> Result, RmpEncodeError> { + check_geometry(rows, cols) + .map_err(|e| RmpEncodeError::Syntax(format!("ASAPv1 envelope: {e}")))?; + rmp_serde::to_vec_named(&cs_metadata::( + rows as u32, + cols as u32, + T::COUNTER_TYPE, + Mode::MODE, + )) +} + +/// Reads a metadata block and returns its `(rows, cols)`. The hash spec, +/// `counter_type` and `mode` must be this configuration's; `rows` / `cols` are +/// echoed back and must be a wire-eligible geometry, checked before anything is +/// sized from them. +pub(super) fn decode_metadata( + block: &[u8], +) -> Result<(usize, usize), RmpDecodeError> { + let meta: CsMetadata = envelope::decode_block(block)?; + if meta != cs_metadata::(meta.rows, meta.cols, T::COUNTER_TYPE, Mode::MODE) { + return Err(RmpDecodeError::Uncategorized( + "ASAPv1 Count Sketch envelope: metadata mismatch".to_string(), + )); + } + let (rows, cols) = (meta.rows as usize, meta.cols as usize); + check_geometry(rows, cols).map_err(RmpDecodeError::Uncategorized)?; + Ok((rows, cols)) +} + /// Count Sketch payload (ASAPv1 §3.6), a msgpack **array** (`to_vec`, /// positional): `[counts]` — a 1-element array. The dimensions live in the /// metadata; `counts` is packed row-major and its element type is fixed by the @@ -112,6 +163,10 @@ fn cs_metadata(rows: u32, cols: u32, counter_type: &str, mode: & /// counter may be negative. #[derive(Debug, Serialize, Deserialize)] struct CsPayload { + #[serde( + deserialize_with = "exact_vec", + bound(deserialize = "T: Deserialize<'de>") + )] counts: Vec, } @@ -140,15 +195,7 @@ where let rows = self.counts.rows(); let cols = self.counts.cols(); let counts = self.counts.as_slice(); - // The decoder refuses a zero dimension, so the encoder must too: with - // `rows == 0` the length check below passes trivially (`0 == 0*cols`). - if rows == 0 || cols == 0 { - return Err(RmpEncodeError::Syntax(format!( - "ASAPv1 Count Sketch envelope: dimensions must be non-zero: rows={rows}, cols={cols}" - ))); - } - check_matrix_rows("Count Sketch", rows) - .map_err(|e| RmpEncodeError::Syntax(format!("ASAPv1 Count Sketch envelope: {e}")))?; + let metadata = encode_metadata::(rows, cols)?; if counts.len() != rows.saturating_mul(cols) { return Err(RmpEncodeError::Syntax(format!( "ASAPv1 Count Sketch envelope: counts length {} != rows*cols {}", @@ -156,17 +203,6 @@ where rows.saturating_mul(cols) ))); } - let cols_u32 = u32::try_from(cols).map_err(|_| { - RmpEncodeError::Syntax(format!( - "ASAPv1 Count Sketch envelope: cols {cols} exceeds u32" - )) - })?; - let metadata = rmp_serde::to_vec_named(&cs_metadata::( - rows as u32, - cols_u32, - T::COUNTER_TYPE, - Mode::MODE, - ))?; let payload = rmp_serde::to_vec(&CsPayload:: { counts: counts.to_vec(), })?; @@ -184,26 +220,8 @@ where "Count Sketch kind_id mismatch: stored {kind_id:?}, expected {CS_KIND:?}" ))); } - let meta: CsMetadata = envelope::decode_block(metadata)?; - // Validate the hash spec + counter type + mode against this target; - // `rows`/`cols` are structural (the sketch is dynamically sized), so they - // are echoed back into the expected block rather than known a priori. - if meta != cs_metadata::(meta.rows, meta.cols, T::COUNTER_TYPE, Mode::MODE) { - return Err(RmpDecodeError::Uncategorized( - "ASAPv1 Count Sketch envelope: metadata mismatch".to_string(), - )); - } - let (rows, cols) = (meta.rows as usize, meta.cols as usize); + let (rows, cols) = decode_metadata::(metadata)?; let p: CsPayload = envelope::decode_block(payload)?; - // Reject zero dimensions before building the matrix: `Vector2D::from_fn` - // derives its mask via `cols.ilog2()`, which panics on `cols == 0`. Fail - // closed with an error rather than panicking on crafted bytes. - if rows == 0 || cols == 0 { - return Err(RmpDecodeError::Uncategorized(format!( - "Count Sketch dimensions must be non-zero: rows={rows}, cols={cols}" - ))); - } - check_matrix_rows("Count Sketch", rows).map_err(RmpDecodeError::Uncategorized)?; // Length check precedes the allocation, so crafted dimensions cannot // drive `from_fn` into a huge reserve: the payload must actually carry // `rows*cols` decoded counters. @@ -540,10 +558,9 @@ mod tests { assert!(rmp_serde::from_slice::(&bytes).is_err()); } - /// `f64` is a Count-Min wire counter but not a Count Sketch one, so its - /// name must not decode into either wire-eligible type. + /// An `f64`-labelled envelope decodes into neither integer sketch. #[test] - fn cs_metadata_rejects_a_foreign_counter_type_name() { + fn cs_metadata_rejects_f64_into_an_integer_sketch() { let bytes = rmp_serde::to_vec_named(&cs_metadata::(2, 4, "f64", "regular")) .unwrap(); diff --git a/src/sketches/countsketch_topk/wire.rs b/src/sketches/countsketch_topk/wire.rs index 3a5e1560..abe35222 100644 --- a/src/sketches/countsketch_topk/wire.rs +++ b/src/sketches/countsketch_topk/wire.rs @@ -10,7 +10,7 @@ //! //! CSHeap is one algorithm — a single kind_id `0x0a 0x00`. The structural //! parameters — the matrix dimensions (`rows` / `cols`), the base **counter -//! type** (i32/i64), the column-derivation **mode** (fast/regular), the heap +//! type** (i32/i64/f64), the column-derivation **mode** (fast/regular), the heap //! capacity `k` and the heap's `key_type` — all live in the metadata, so the //! payload is `[counts, keys, heap_counts]`: the base matrix packed row-major //! followed by the heap's entries. @@ -482,10 +482,9 @@ mod tests { ); } - /// `f64` is a Count-Min wire counter but not a Count Sketch one, so its name - /// must not decode into either wire-eligible type. + /// An `f64`-labelled envelope decodes into neither integer sketch. #[test] - fn cs_heap_rejects_a_foreign_counter_type_name() { + fn cs_heap_rejects_f64_into_an_integer_sketch() { let metadata = rmp_serde::to_vec_named(&topk_metadata::( 2, 4, "f64", "regular", 4, "u64", )) diff --git a/src/sketches/mod.rs b/src/sketches/mod.rs index 6e04cbe6..80ca1433 100644 --- a/src/sketches/mod.rs +++ b/src/sketches/mod.rs @@ -54,7 +54,7 @@ pub use bloom::{ }; pub mod countsketch; -pub use countsketch::Count; +pub use countsketch::{Count, CountSketchDelta}; /// Count-Min-shaped grid with a per-bucket HyperLogLog (distinct-count) sketch. #[cfg(feature = "experimental")] diff --git a/tests/asapv1_golden.rs b/tests/asapv1_golden.rs index 0ec8b718..cd827899 100644 --- a/tests/asapv1_golden.rs +++ b/tests/asapv1_golden.rs @@ -8,6 +8,7 @@ //! //! See `asapv1_golden/README.md`. +use asap_sketchlib::sketches::CountSketchDelta; use asap_sketchlib::{ Classic, Count, CountMin, CountMinDelta, ErtlMLE, FastPath, HllSketch, HllVariant, HyperLogLogHIPP12, HyperLogLogP12, KLL, MessagePackCodec, RegularPath, Vector2D, peek_kind_id, @@ -32,6 +33,9 @@ const GOLDEN_CMS_F64_DELTA: &str = include_str!("../asapv1_golden/cms_f64_fast_2 const GOLDEN_CS_REGULAR: &str = include_str!("../asapv1_golden/cs_i64_regular_2x4.hex"); const GOLDEN_CS_FAST: &str = include_str!("../asapv1_golden/cs_i64_fast_2x4.hex"); const GOLDEN_CS_I32: &str = include_str!("../asapv1_golden/cs_i32_regular_2x4.hex"); +const GOLDEN_CS_F64_FAST: &str = include_str!("../asapv1_golden/cs_f64_fast_2x4.hex"); +const GOLDEN_CS_I64_DELTA: &str = include_str!("../asapv1_golden/cs_i64_regular_2x4_delta.hex"); +const GOLDEN_CS_F64_DELTA: &str = include_str!("../asapv1_golden/cs_f64_fast_2x4_delta.hex"); const GOLDEN_KLL_F64: &str = include_str!("../asapv1_golden/kll_f64_k200.hex"); const GOLDEN_KLL_I64: &str = include_str!("../asapv1_golden/kll_i64_k200.hex"); @@ -54,6 +58,9 @@ const F64_VALS: [[f64; 3]; 2] = [[0.0, 1.5, 2.25], [3.75, 4.125, 5.0625]]; /// fixint / int8 / int16 / int32. const CS_VALS: [[i64; 4]; 2] = [[0, 127, 128, 65536], [-1, -33, -32768, -2147483648]]; +/// The f64 Count Sketch matrix: signed, fractional, and both zeros. +const CS_F64_VALS: [[f64; 4]; 2] = [[0.0, -0.0, 1.5, -2.25], [127.0, -32768.5, 0.125, -1e300]]; + // --------------------------------------------------------------------------- // HLL: build known state -> serialize == golden, and golden round-trips. // --------------------------------------------------------------------------- @@ -207,8 +214,8 @@ fn cms_f64_fast_2x3_delta_matches_golden() { // --------------------------------------------------------------------------- // Count Sketch: build known matrix state -> serialize == golden, round-trips. -// All three fixtures hold the SAME matrix: the i64 pair differs only by `mode` -// and the i32 fixture only by `counter_type`, so the set pins that both +// The three integer fixtures hold the SAME matrix: the i64 pair differs only by +// `mode` and the i32 fixture only by `counter_type`, so the set pins that both // metadata keys reach the bytes and that nothing else does. // --------------------------------------------------------------------------- @@ -285,6 +292,62 @@ fn cs_i32_regular_2x4_matches_golden() { ); } +#[test] +fn cs_f64_fast_2x4_matches_golden() { + type Cs = Count, FastPath>; + let want = decode_hex(GOLDEN_CS_F64_FAST); + let sketch = Cs::from_storage(Vector2D::from_fn(2, 4, |r, c| CS_F64_VALS[r][c])); + assert_eq!(sketch.serialize_to_bytes().expect("serialize"), want); + + let decoded = Cs::deserialize_from_bytes(&want).expect("decode"); + let flat: Vec = CS_F64_VALS.iter().flatten().copied().collect(); + assert_eq!(decoded.as_storage().as_slice(), flat.as_slice()); + assert_eq!(decoded.serialize_to_bytes().expect("re-serialize"), want); +} + +// --------------------------------------------------------------------------- +// Count Sketch delta: build known delta -> serialize == golden, round-trips, +// and merged into the matching state golden gives the expected state. +// --------------------------------------------------------------------------- + +#[test] +fn cs_i64_regular_2x4_delta_matches_golden() { + type Cs = Count, RegularPath>; + type Delta = CountSketchDelta; + let want = decode_hex(GOLDEN_CS_I64_DELTA); + let indices = vec![0, 1, 2, 4, 6, 7]; + let deltas = vec![-32, -128, 255, -129, 65535, -2147483649]; + let delta = Delta::new(2, 4, indices, deltas).expect("valid"); + assert_eq!(delta.serialize_to_bytes().expect("serialize"), want); + + let decoded = Delta::deserialize_from_bytes(&want).expect("decode"); + assert_eq!(decoded.serialize_to_bytes().expect("re-serialize"), want); + + let mut state = Cs::deserialize_from_bytes(&decode_hex(GOLDEN_CS_REGULAR)).expect("state"); + state.merge_delta(&decoded).expect("merge"); + let expected = [-32, -1, 383, 65536, -130, -33, 32767, -4294967297]; + assert_eq!(state.as_storage().as_slice(), expected.as_slice()); +} + +#[test] +fn cs_f64_fast_2x4_delta_matches_golden() { + type Cs = Count, FastPath>; + type Delta = CountSketchDelta; + let want = decode_hex(GOLDEN_CS_F64_DELTA); + let indices = vec![1, 3, 5, 7]; + let deltas = vec![-0.75, 2.25, 0.5, 1e300]; + let delta = Delta::new(2, 4, indices, deltas).expect("valid"); + assert_eq!(delta.serialize_to_bytes().expect("serialize"), want); + + let decoded = Delta::deserialize_from_bytes(&want).expect("decode"); + assert_eq!(decoded.serialize_to_bytes().expect("re-serialize"), want); + + let mut state = Cs::deserialize_from_bytes(&decode_hex(GOLDEN_CS_F64_FAST)).expect("state"); + state.merge_delta(&decoded).expect("merge"); + let expected = [0.0, -0.75, 1.5, 0.0, 127.0, -32768.0, 0.125, 0.0]; + assert_eq!(state.as_storage().as_slice(), expected.as_slice()); +} + // --------------------------------------------------------------------------- // KLL: build known state (k=200, seed 42, integers 1..=50 — below the level-0 // capacity, so no compaction fires and the retained set is deterministic) -> From d2032be3813a65b8104baadb51963fce36b7f81a Mon Sep 17 00:00:00 2001 From: GordonYuanyc Date: Fri, 16 Oct 2026 07:16:46 +0000 Subject: [PATCH 3/7] feat(cmsheap): f64 heap counts, decode-side heap order and the CMSHeap delta (03 80) HHHeap and HHItem are generic over the count (i64 by default; f64 ranks by total_cmp), so an f64 CMSHeap inserts and merges with unrounded heap counts. Heap decoders reject entries out of emitted order. The CMSHeap and CSHeap metadata key k is heap_size. CMSHeapDelta adds cells and replaces the heap, all or nothing. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 + asapv1_golden | 2 +- docs/api/api_cms_heap.md | 60 ++- docs/api/api_common.md | 2 +- docs/api/api_common_heap.md | 20 +- docs/api/api_common_input.md | 10 +- docs/api/api_cs_heap.md | 19 +- docs/asapv1_wire_format.md | 8 +- docs/tests.md | 31 +- src/common/heap.rs | 81 ++- src/common/input.rs | 29 +- src/common/mod.rs | 2 +- src/message_pack_format/envelope.rs | 4 + src/message_pack_format/wire_key.rs | 22 +- src/sketches/countminsketch_topk.rs | 59 ++- src/sketches/countminsketch_topk/delta.rs | 210 ++++++++ src/sketches/countminsketch_topk/heap_wire.rs | 486 ++++++++++++------ src/sketches/countminsketch_topk/wire.rs | 147 +++--- src/sketches/countsketch_topk/wire.rs | 118 ++--- src/sketches/mod.rs | 2 +- tests/asapv1_golden.rs | 193 ++++++- tests/e2e/matrix_instances.rs | 49 +- 22 files changed, 1115 insertions(+), 444 deletions(-) create mode 100644 src/sketches/countminsketch_topk/delta.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 36fcc958..71b27e49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,9 +75,14 @@ signals a backwards-compatible change. and `f64` Count Sketch counters (`counter_type` `"f64"`): the delta is built with `new` from changed cells, serialized like a sketch, and added with `Count::merge_delta`, which changes nothing when it fails. +- **`CMSHeapDelta`**, the ASAPv1 CMSHeap delta (`0x03 0x80`), and `f64` + CMSHeap insert and merge, with unrounded `f64` heap counts. ### Changed +- **The CMSHeap and CSHeap ASAPv1 metadata key `k` is renamed `heap_size`**, + and the CMSHeap, CSHeap and UnivMon decoders reject heap entries out of + emitted order. `HHItem` and `HHHeap` take a count type, `i64` by default. - **ASAPv1 decoders reject a block with bytes after its msgpack value, and a float field that is not msgpack float64** (an `f32` key: not float32). - **The ASAPv1 golden fixtures live in their own repo**, diff --git a/asapv1_golden b/asapv1_golden index b20764b6..8f225608 160000 --- a/asapv1_golden +++ b/asapv1_golden @@ -1 +1 @@ -Subproject commit b20764b6352245f714c6bc71d05d9a56ea0434d6 +Subproject commit 8f225608466e3dee8b11754546d3b42a44239184 diff --git a/docs/api/api_cms_heap.md b/docs/api/api_cms_heap.md index 41c0c567..82bdfb03 100644 --- a/docs/api/api_cms_heap.md +++ b/docs/api/api_cms_heap.md @@ -10,6 +10,10 @@ Count-Min with integrated heavy-hitter heap (`HHHeap`) for top-k tracking. - `CMSHeap, M = RegularPath, H = DefaultXxHasher>` +The heap is an `HHHeap` whose count type `C` is `S::Counter`'s +`TopKCounter::HeapCount`: `f64` over `f64` counters, `i64` over `i32`, `i64` +and `i128`. Each entry's count is the sketch's estimate, unrounded. + ## Constructors ```rust @@ -34,7 +38,7 @@ fn estimate(&self, key: &DataInput) -> S::Counter fn rows(&self) -> usize fn cols(&self) -> usize fn cms(&self) -> &CountMin -fn heap(&self) -> &HHHeap +fn heap(&self) -> &HHHeap ``` ## Merge @@ -59,14 +63,15 @@ on every `CMSHeap`: the impl exists only for wire-eligible configs (only you know if the mapping is lossless). A sketch travels as the base matrix plus the heap's entries: the metadata -carries `rows`, `cols`, `counter_type`, `mode`, the heap capacity `k` and the -heap's `key_type`, and the payload is `[counts, keys, heap_counts]`. The heap's -digest index is rebuilt on load, so no index reaches the wire, and `k` never -sizes an allocation on decode. The base matrix is bound by the same -`1 <= rows <= 20` (`MATRIX_MAX_ROWS`, the seed list length) the stand-alone -sketch is: past that, the regular path gives row `r` and row `r + 20` the same -seed and identical counters, so a wider matrix is refused on both sides in -either mode. +carries `rows`, `cols`, `counter_type`, `mode`, the heap capacity `heap_size` +and the heap's `key_type`, and the payload is `[counts, keys, heap_counts]`, +with `heap_counts` `f64` under `counter_type` `"f64"` and `i64` otherwise. The +heap's digest index is rebuilt on load, so no index reaches the wire, and +`heap_size` never sizes an allocation on decode. The base matrix is bound by +the same `1 <= rows <= 20` (`MATRIX_MAX_ROWS`, the seed list length) the +stand-alone sketch is: past that, the regular path gives row `r` and row +`r + 20` the same seed and identical counters, so a wider matrix is refused on +both sides in either mode. Heap keys are `HeapItem`s, so the key type is a runtime property: `key_type` names the **exact** variant (`"i32"` stays `"i32"`, never widened to `"i64"`) @@ -74,8 +79,33 @@ and the `keys` array is homogeneous in it. A heap whose keys mix variants, or holds an `I128` / `U128` key, does not serialize. An empty heap emits `key_type = "u64"`. -Entries are emitted in descending count, ties broken by a total order over the -key, so a decoded sketch re-serializes byte-identically. +Entries are emitted in descending count (`f64::total_cmp` for `f64` counts), +ties broken by a total order over the key, so a decoded sketch re-serializes +byte-identically. The decoder rejects entries in any other order. + +## Deltas + +```rust +CMSHeapDelta + +fn new(rows: usize, cols: usize, indices: Vec, deltas: Vec, + heap: HHHeap) -> Result +fn serialize_to_bytes(&self) -> Result, RmpEncodeError> +fn deserialize_from_bytes(bytes: &[u8]) -> Result + +// on CMSHeap, Mode, H> +fn merge_delta(&mut self, delta: &CMSHeapDelta) + -> Result<(), String> +``` + +A `CMSHeapDelta` is the ASAPv1 delta kind `0x03 0x80`, for the same configs as +the state serialization: the changed cells as `CountMinDelta`'s (strictly +ascending row-major indices below `rows * cols`, each with a non-zero value) +and the producer's whole heap, encoded as the state's heap is. `new` and the +decoder refuse anything else. `merge_delta` adds the cells to the counters and +replaces the heap with the delta's, its key type included; a delta of other +dimensions or heap capacity, or an integer add that overflows, fails and leaves +the sketch unchanged. ## Examples @@ -91,11 +121,9 @@ assert!(sk.estimate(&DataInput::Str("flow")) >= 1); - Heap can be cleared independently of sketch counters. - Merge requires matching dimensions and compatible type parameters. -- **Eight built-in instances are constructible but inert.** `insert`, - `insert_many`, `estimate` and `merge` live in an impl bounded on - `S::Counter: Copy + Ord + From + Into + AddAssign`. `i128` has no - `Into` and `f64` has neither `Ord` nor `Into`, so - `CMSHeap>`, `CMSHeap>`, +- **Six built-in instances are constructible but inert.** `insert`, + `insert_many`, `estimate` and `merge` need `S::Counter` to convert into its + heap count. `i128` has no `Into`, so `CMSHeap>`, `CMSHeap` and `CMSHeap` — on both hashing paths — allocate a sketch and a heap and can then do nothing but report their dimensions. Use `CSHeap` when you need a 128-bit counter beside a top-k heap; diff --git a/docs/api/api_common.md b/docs/api/api_common.md index 7f89fd4e..044bf4c2 100644 --- a/docs/api/api_common.md +++ b/docs/api/api_common.md @@ -63,7 +63,7 @@ use asap_sketchlib::common::{ CommonHeap, CommonHeapOrder, KeepSmallest, KeepLargest, // HH helper + utilities - HHHeap, + HHHeap, HeapCount, TopKCounter, Nitro, compute_median_inline_f64, // Precomputed assets diff --git a/docs/api/api_common_heap.md b/docs/api/api_common_heap.md index 57521b5a..1897aa8e 100644 --- a/docs/api/api_common_heap.md +++ b/docs/api/api_common_heap.md @@ -8,11 +8,15 @@ Common heavy-hitter heap helper used by multiple sketches/frameworks. ## Type/Struct -- `HHHeap` +- `HHHeap` -A capacity-bounded min-heap of `HHItem` beside an index from key digest to heap -position, so a keyed update is a lookup and a sift rather than a scan. The index -is patched through each sift: `update` costs one hash, one map probe and +`C` is the count type, `i64` or `f64`; `f64` counts are ranked by +`f64::total_cmp`. `TopKCounter` maps a matrix counter type to the heap count +type a top-k sketch keeps beside it. + +A capacity-bounded min-heap of `HHItem` beside an index from key digest to +heap position, so a keyed update is a lookup and a sift rather than a scan. The +index is patched through each sift: `update` costs one hash, one map probe and `O(log k)` index writes, and its rate does not fall as `k` grows. See [HHHeap acceleration](../hhheap_acceleration.md) for the measurements. @@ -20,14 +24,14 @@ is patched through each sift: `update` costs one hash, one map probe and ```rust fn new(k: usize) -> Self -fn from_heap(other: &HHHeap) -> Self +fn from_heap(other: &HHHeap) -> Self ``` ## Insert/Update ```rust -fn update(&mut self, key: &DataInput, count: i64) -> bool -fn update_heap_item(&mut self, key: &HeapItem, count: i64) -> bool +fn update(&mut self, key: &DataInput, count: C) -> bool +fn update_heap_item(&mut self, key: &HeapItem, count: C) -> bool fn clear(&mut self) ``` @@ -36,7 +40,7 @@ fn clear(&mut self) ```rust fn find(&self, key: &DataInput) -> Option fn find_heap_item(&self, key: &HeapItem) -> Option -fn heap(&self) -> &[HHItem] +fn heap(&self) -> &[HHItem] fn len(&self) -> usize fn is_empty(&self) -> bool fn capacity(&self) -> usize diff --git a/docs/api/api_common_input.md b/docs/api/api_common_input.md index 3a68d538..86d98f8c 100644 --- a/docs/api/api_common_input.md +++ b/docs/api/api_common_input.md @@ -12,7 +12,7 @@ Canonical input and ownership model shared across sketches/frameworks. - `DataInput<'a>` - `HeapItem` -- `HHItem` +- `HHItem` - `HydraQuery<'a>` - `HydraCounter` - `L2HH` @@ -25,10 +25,10 @@ Canonical input and ownership model shared across sketches/frameworks. fn heap_item_to_sketch_input(item: &HeapItem) -> DataInput<'_> fn input_to_owned(input: &DataInput<'_>) -> HeapItem -// HHItem -fn new(k: DataInput, count: i64) -> Self -fn create_item(k: HeapItem, count: i64) -> Self -fn init_item(k: DataInput, count: i64) -> Self +// HHItem +fn new(k: DataInput, count: C) -> Self +fn create_item(k: HeapItem, count: C) -> Self +fn init_item(k: DataInput, count: C) -> Self ``` ## Insert/Update diff --git a/docs/api/api_cs_heap.md b/docs/api/api_cs_heap.md index f39e9b30..eb9cdbde 100644 --- a/docs/api/api_cs_heap.md +++ b/docs/api/api_cs_heap.md @@ -60,14 +60,14 @@ on every `CSHeap`: the impl exists only for wire-eligible configs mapping is lossless). A sketch travels as the base matrix plus the heap's entries: the metadata -carries `rows`, `cols`, `counter_type`, `mode`, the heap capacity `k` and the -heap's `key_type`, and the payload is `[counts, keys, heap_counts]`. The heap's -digest index is rebuilt on load, so no index reaches the wire, and `k` never -sizes an allocation on decode. The base matrix is bound by the same -`1 <= rows <= 20` (`MATRIX_MAX_ROWS`, the seed list length) the stand-alone -sketch is: past that, the regular path gives row `r` and row `r + 20` the same -seed and identical counters, so a wider matrix is refused on both sides in -either mode. +carries `rows`, `cols`, `counter_type`, `mode`, the heap capacity `heap_size` +and the heap's `key_type`, and the payload is `[counts, keys, heap_counts]`. +The heap's digest index is rebuilt on load, so no index reaches the wire, and +`heap_size` never sizes an allocation on decode. The base matrix is bound by +the same `1 <= rows <= 20` (`MATRIX_MAX_ROWS`, the seed list length) the +stand-alone sketch is: past that, the regular path gives row `r` and row +`r + 20` the same seed and identical counters, so a wider matrix is refused on +both sides in either mode. Heap keys are `HeapItem`s, so the key type is a runtime property: `key_type` names the **exact** variant (`"i32"` stays `"i32"`, never widened to `"i64"`) @@ -76,7 +76,8 @@ holds an `I128` / `U128` key, does not serialize. An empty heap emits `key_type = "u64"`. Entries are emitted in descending count, ties broken by a total order over the -key, so a decoded sketch re-serializes byte-identically. +key, so a decoded sketch re-serializes byte-identically. The decoder rejects +entries in any other order. `CountL2HH` is a different algorithm with its own ASAPv1 kind (`0x19 0x00`); see [the Count Sketch API doc](./api_count_sketch.md). diff --git a/docs/asapv1_wire_format.md b/docs/asapv1_wire_format.md index c024ed4d..df85b5c3 100644 --- a/docs/asapv1_wire_format.md +++ b/docs/asapv1_wire_format.md @@ -26,7 +26,7 @@ If the doc feels long, these sections carry the key points: ## Status -- **Implemented (Rust).** Every kind_id the Section 1 registry marks *implemented* serializes through the shared `message_pack_format::envelope` module per this spec: HLL (three estimators), Count-Min with its delta (`CountMinDelta`), CMSHeap, Count Sketch with its delta (`sketches::CountSketchDelta`), CSHeap, DDSketch, KLL (compact + dynamic), Hydra (five counter variants), Elastic, Coco, UniformSampling, KMV, UnivMon, UnivMon Optimized, ExponentialHistogram, EHSketchList, Bloom, Space-Saving, CountL2HH and UnivMon-Q. +- **Implemented (Rust).** Every kind_id the Section 1 registry marks *implemented* serializes through the shared `message_pack_format::envelope` module per this spec: HLL (three estimators), Count-Min with its delta (`CountMinDelta`), CMSHeap with its delta (`CMSHeapDelta`), Count Sketch with its delta (`sketches::CountSketchDelta`), CSHeap, DDSketch, KLL (compact + dynamic), Hydra (five counter variants), Elastic, Coco, UniformSampling, KMV, UnivMon, UnivMon Optimized, ExponentialHistogram, EHSketchList, Bloom, Space-Saving, CountL2HH and UnivMon-Q. - **Self-describing.** The hash-spec metadata is derived from the hasher's `HashProfile` (read live, never hardcoded), so the bytes truthfully describe how a sketch was hashed; custom hash profiles are supported (Section 2). - **Byte-level encoding** is pinned in Section 4; the resolved decisions are summarized at the end. - **`sketchlib-go`** is aligned separately (see Cross-language contract). @@ -191,7 +191,7 @@ This registry is the master list of algorithms still to design payloads for, and | `0x02 0x00` | Count-Min | Count-Min | Section 3.2 | implemented | | `0x02 0x80` | Count-Min | Count-Min delta | Section 3.23 | implemented | | `0x03 0x00` | Count-Min-with-heap (CMSHeap) | Count-Min + top-k heap | Section 3.7 | implemented | -| `0x03 0x80` | Count-Min-with-heap (CMSHeap) | CMSHeap delta | Section 3.23 | designed | +| `0x03 0x80` | Count-Min-with-heap (CMSHeap) | CMSHeap delta | Section 3.23 | implemented | | `0x04 0x00` | Count Sketch | Count Sketch | Section 3.6 | implemented | | `0x04 0x80` | Count Sketch | Count Sketch delta | Section 3.23 | implemented | | `0x05 0x00` | DDSketch | Logarithmic bucket stores | Section 3.8 | implemented | @@ -1038,7 +1038,7 @@ UnivMon carries the hash-spec group — it hashes, and a consumer must reproduce 10. `update_mode` is `0`, `1` or `2`; any other value is rejected. 11. Each layer's heap entries are in the emitted order above; any other order is rejected. -The encode side enforces the same shape rules — a `layers` past 64, a layer that is not the declared size, a layer hashing at another layer's seed index, mixed or 128-bit keys, a layer count that disagrees with the declared `layers` — so the format never emits bytes it would refuse to read back. Rule 7's duplicate-key half is the exception, and it is the decode door's alone here where §3.7's is on both: a layer heap holding the same `NaN` twice (§3.7's producer-side constraint) serializes, and the bytes are refused when they are read back. +The encode side enforces the same shape rules — a `layers` past 64, a layer that is not the declared size, a layer hashing at another layer's seed index, mixed or 128-bit keys, a layer count that disagrees with the declared `layers` — and rule 7, so the format never emits bytes it would refuse to read back: a layer heap holding the same `NaN` twice (§3.7's producer-side constraint) fails to serialize. ### 3.16: UnivMon Optimized payload (`0x11 0x00`) @@ -1069,7 +1069,7 @@ The encode side enforces the same shape rules — a `layers` past 64, a layer th 4. The declared layout's accumulator count — `min(elephant_layers, layers) * elephant_rows + max(0, layers - elephant_layers) * mouse_rows` — equals `len(l2)` exactly, computed with checked arithmetic and checked **before** the per-layer geometry is built. With rule 3's ceiling above it, that is what makes a crafted `layers` or tier width cost nothing. 5. §3.15's rules 4, 6, 7, 8, 9, 10 and 11 apply verbatim over the derived per-layer geometry — including the per-layer `l2[i] >= 0` check and the per-layer 128-bit hash-budget check (§3.19). -The encode side enforces §3.15's shape rules over the two-tier layout, the `layers <= 64` ceiling included, and inherits §3.15's one exception, the duplicate-key half of its rule 7. +The encode side enforces §3.15's shape rules and rule 7 over the two-tier layout, the `layers <= 64` ceiling included. **`UnivSketchPool` is not a wire kind.** It is a `Vec` free list of pre-allocated scratch `UnivMon`s — `0x10 0x00`'s type, not this section's — beside a `total_allocated` counter: an allocator, not a sketch, with nothing an observer could query. `0x11 0x00` is `UnivMonPyramid`'s alone. diff --git a/docs/tests.md b/docs/tests.md index 79a55c17..93fe9a6e 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -237,11 +237,14 @@ Wire tests: [`src/sketches/countminsketch_topk/wire.rs`](../src/sketches/countmi Shared heap wire tests: [`src/sketches/countminsketch_topk/heap_wire.rs`](../src/sketches/countminsketch_topk/heap_wire.rs) +Delta tests: [`src/sketches/countminsketch_topk/delta.rs`](../src/sketches/countminsketch_topk/delta.rs) + | test_name | test_description | what_is_tested | | --- | --- | --- | | `insert_and_estimate` | Repeated inserts increment Count-Min estimate for one key. | Inserts `"hello"` 5 times into `CMSHeap::new(3,64,10)` and verifies `estimate("hello") == 5`. | | `heap_tracks_top_k` | Heap keeps highest-frequency keys within top-k capacity. | Inserts keys `1..5` with frequencies `10,20,30,40,50` into `top_k=3` sketch and verifies heap counts are exactly `[30,40,50]`. | | `merge_reconciles_heaps` | Merge combines counters and refreshes heap counts from merged sketch. | Merges two sketches containing `"merge_key"` counts `10` and `20`, then verifies merged estimate and heap count are both `30`. | +| `f64_heap_counts_are_the_unrounded_estimate` | An `f64` sketch's heap holds the unrounded estimate. | On both paths, verifies `insert_many("flow", 2.25)` leaves heap count `2.25` and a merge with a copy leaves `4.5`. | | `insert_many_updates_estimate_and_heap` | `insert_many` updates estimate and heap entry consistently. | Calls `insert_many("many", 11)` and verifies `estimate == 11` plus heap entry count `11`. | | `bulk_insert_updates_multiple_keys` | `bulk_insert` updates multiple keys and heavy-hitter counts correctly. | Inserts stream `[7,8,7,9,7]` and verifies estimates `7->3`, `8->1`, `9->1`, with heap count for key `7` equal to `3`. | | `clear_heap_keeps_cms_counters` | Clearing heap does not clear CMS counters. | After `insert_many("persist",5)`, calls `clear_heap()`, verifies estimate remains `5`, then one more insert rebuilds heap entry to `6`. | @@ -262,7 +265,7 @@ Shared heap wire tests: [`src/sketches/countminsketch_topk/heap_wire.rs`](../src | `zipf_stream_top_k_recall_regular_fast_budget` | Regular path heap achieves high top-k recall on Zipf stream. | On Zipf stream (`rows=3`, `cols=4096`, `top_k=16`, `domain=1024`, `exponent=1.1`, `N=20_000`), verifies heap size bound, entry-count consistency, and recall hits `>= 15` vs truth top-16. | | `zipf_stream_top_k_recall_fast_path_fast_budget` | Fast path heap achieves high top-k recall on Zipf stream. | Runs same Zipf setup in fast mode and verifies heap size bound, entry-count consistency, and recall hits `>= 15`. | | `zipf_stream_regular_fast_heap_overlap` | Regular and fast heaps substantially overlap on Zipf heavy hitters. | On shared Zipf stream (`top_k=16`), verifies key overlap ratio between regular and fast top-k heaps is at least `0.8`. | -| `cms_heap_round_trip_serialization` | The envelope frames a sketch under kind_id `0x03 0x00`, and the bytes are stable across a round trip. | For a `3x8` regular-path `i64` sketch with a heap of `4` holding three weighted `u64` keys, verifies the bytes open with the ASAPv1 magic, a `kind_id_len` of `2`, and `0x03 0x00`, that the metadata carries `3x8`, `counter_type` `i64`, `mode` `regular`, `k` `4`, and `key_type` `u64`, that dimensions, counters, heap length and capacity, and per-key estimates and heap counts all match, and that re-serializing reproduces the bytes. | +| `cms_heap_round_trip_serialization` | The envelope frames a sketch under kind_id `0x03 0x00`, and the bytes are stable across a round trip. | For a `3x8` regular-path `i64` sketch with a heap of `4` holding three weighted `u64` keys, verifies the bytes open with the ASAPv1 magic, a `kind_id_len` of `2`, and `0x03 0x00`, that the metadata carries `3x8`, `counter_type` `i64`, `mode` `regular`, `heap_size` `4`, and `key_type` `u64`, that dimensions, counters, heap length and capacity, and per-key estimates and heap counts all match, and that re-serializing reproduces the bytes. | | `cms_heap_byte_keys_round_trip` | A raw byte-array heap key reaches the wire as msgpack `bin`. | For a `2x4` `i64` sketch whose heap holds `[0xff, 0x00, 0xfe]` at `11`, verifies the metadata names `key_type` `bytes`, the decoded heap still finds the key by its own `DataInput::Bytes` at that count, and the bytes re-serialize identically. | | `cms_heap_f64_counters_round_trip` | The `f64` base counter reaches the wire beside a string-keyed heap. | For a `2x4` `Vector2D` sketch holding one `Str` heap key, verifies the metadata names `counter_type` `f64` and `key_type` `string`, that the counters and the heap key survive the decode, and that the bytes re-serialize identically. | | `cms_heap_counter_type_is_pinned_by_the_target` | The base counter type separates two otherwise equal sketches. | For `2x4` sketches holding numerically equal `i64` and `f64` cells, verifies the bytes differ, that `i64` bytes fail to decode as `f64`, and that `f64` bytes fail to decode as `i64`. | @@ -270,21 +273,26 @@ Shared heap wire tests: [`src/sketches/countminsketch_topk/heap_wire.rs`](../src | `cms_heap_rejects_foreign_kind_ids` | The neighbouring sketches' kind_ids are refused. | Verifies `CSHeap`, `CountMin`, and `Count` envelopes at `3x8` each fail to decode as a `CMSHeap`. | | `cms_heap_rejects_zero_dimension_payload` | A zero dimension is a decode error, not a `Vector2D::from_fn` panic. | Verifies a crafted `4x0` envelope fails with a "must be non-zero" complaint. | | `cms_heap_rejects_dimension_length_mismatch` | The length check fires from the declared dimensions, before any allocation is sized from them. | Verifies a crafted envelope declaring `MATRIX_MAX_ROWS x 2^24` while carrying three counters fails with a "!= rows*cols" complaint. | +| `cms_heap_rejects_serializing_zero_rows` | The encode side refuses a zero dimension. | Verifies a `0x4` sketch fails to serialize with a "non-zero" complaint. | | `cms_heap_rejects_serializing_an_unfilled_matrix` | The encode side refuses a matrix its own decoder would reject. | Verifies `Vector2D::::init(2, 4)`, which reserves eight cells without filling them, fails to serialize. | | `cms_heap_metadata_rejects_unknown_keys` | An unexpected metadata key fails closed. | Encodes the thirteen `TopKMetadata` fields plus a `bogus_field` as a named map and verifies it does not decode as `TopKMetadata`. | -| `cms_heap_metadata_rejects_a_missing_k_key` | `k` is required, so the heap capacity can never be silently defaulted. | Encodes the other twelve `TopKMetadata` fields as a named map with `k` omitted and verifies it does not decode as `TopKMetadata`. | +| `cms_heap_metadata_rejects_a_missing_heap_size_key` | `heap_size` is required, so the heap capacity can never be silently defaulted. | Encodes the other twelve `TopKMetadata` fields as a named map with `heap_size` omitted and verifies it does not decode as `TopKMetadata`. | | `cms_heap_rejects_a_foreign_counter_type_name` | `counter_type` is pinned by the target, not merely checked for naming a wire counter. | Wraps metadata naming `counter_type: "i32"` — one of Count-Min's three wire counters, alongside `i64` and `f64` — around a valid `2x4` payload, so the envelope is well-formed and still names a counter this sketch does not hold, and verifies an `i64` `CMSHeap` rejects it. | | `cms_heap_custom_hasher_profile_round_trips_and_is_self_describing` | The metadata describes the hasher that built the sketch rather than a hardcoded profile. | For a `3x8` sketch with a heap of `4` over a hasher declaring its own `HashProfile`, verifies the counters and both heap entries round-trip, the bytes differ from the standard-profile sketch's over the same keys, and a standard-profile decode rejects them. | -| `cms_heap_refuses_a_k_the_metadata_cannot_carry` | A `k` past the metadata's `u32` field fails the encode rather than truncating. | Verifies a `2x4` sketch built at a heap capacity of `1 << 40` fails to serialize with a message naming the "exceeds the u32 metadata field" rule. | +| `cms_heap_refuses_a_heap_size_the_metadata_cannot_carry` | A `heap_size` past the metadata's `u32` field fails the encode rather than truncating. | Verifies a `2x4` sketch built at a heap capacity of `1 << 40` fails to serialize with a message naming the "exceeds the u32 metadata field" rule. | | `heap_every_key_type_round_trips_and_keeps_its_variant` | The `key_type` names the exact `HeapItem` variant and is never widened. | Across all 13 wire key types - `i8`, `i16`, `i32`, `i64`, `isize`, `u8`, `u16`, `u32`, `u64`, `usize`, `f32`, `f64`, and `string` - verifies the metadata carries the expected name, the bytes re-serialize identically, and every decoded key is still found by its original `DataInput` at the weight it was given. | | `heap_refuses_mixed_and_128_bit_keys` | Keys the wire cannot carry refuse to serialize rather than being coerced. | Verifies a heap holding both an `I32` and an `I64` key fails with a "keys mix variants" complaint naming `key_type is i32`, that a lone `I128` or `U128` key fails with a "128-bit" complaint, and that a 128-bit key seated behind a `U64` one fails too. | -| `heap_empty_emits_the_pinned_key_type_and_round_trips` | A heap monitoring nothing has one encoding. | For an empty `2x8` sketch with a heap of `8`, verifies the metadata carries the pinned `EMPTY_KEY_TYPE` and a `k` of `8`, and that the decode holds no entries at capacity `8` with identical re-serialized bytes. | +| `heap_empty_emits_the_pinned_key_type_and_round_trips` | A heap monitoring nothing has one encoding. | For an empty `2x8` sketch with a heap of `8`, verifies the metadata carries the pinned `EMPTY_KEY_TYPE` and a `heap_size` of `8`, and that the decode holds no entries at capacity `8` with identical re-serialized bytes. | | `heap_emitted_order_is_independent_of_seat_order` | The emitted order is descending count with ties broken by the key, not the sift path. | Seats four entries carrying two count ties in one order and in reverse, verifies both serialize to the same bytes, that the payload reads `heap_counts` `[9, 9, 5, 5]` and `keys` `[1, 4, 2, 3]`, and that a decoded heap re-serializes identically. | | `heap_rejects_a_key_type_the_payload_does_not_carry` | A payload relabelled with another `key_type` is refused. | Re-frames a string-keyed payload under a `u64` `key_type` and a `u64`-keyed payload under `string`, and verifies neither decodes. | | `heap_index_is_rebuilt_so_updates_still_move_entries` | `slots` and `positions` are rebuilt on decode, so later updates land the same way. | Round-trips a four-entry heap, applies the same rescore and new-key update to the source and to the decoded copy, and verifies both serialize to the same bytes and agree on every key's count. | -| `heap_does_not_allocate_a_declared_k` | A declared `k` is metadata: it bounds the entry count and is reported as the capacity, while the rebuild reserves from the entries it actually has. | Verifies an envelope declaring `k` `u32::MAX` with two entries decodes, reports that capacity, and holds two entries. `rebuild_heap` checks the entry count against `k` first, then seats the entries into `HHHeap::with_expected_len(k, entries.len())`, whose array and index each reserve `entries.len().min(k).min(PREALLOCATED_SLOTS)` — so the declared `k` sizes no allocation on its own. | -| `heap_rejects_crafted_entry_sets` | Entry sets the heap could not have reached are refused. | Verifies three crafted envelopes each fail with their own complaint: two entries over a `k` of `1`, the same key twice, and two keys against one count. | +| `heap_does_not_allocate_a_declared_heap_size` | A declared `heap_size` is metadata: it bounds the entry count and is reported as the capacity, while the rebuild reserves from the entries it actually has. | Verifies an envelope declaring `heap_size` `u32::MAX` with two entries decodes, reports that capacity, and holds two entries. `rebuild_heap` checks the entry count against `heap_size` first, then seats the entries into `HHHeap::with_expected_len(heap_size, entries.len())`, whose array and index each reserve `entries.len().min(heap_size).min(PREALLOCATED_SLOTS)` — so the declared `heap_size` sizes no allocation on its own. | +| `heap_rejects_crafted_entry_sets` | Entry sets the heap could not have reached are refused. | Verifies three crafted envelopes each fail with their own complaint: two entries over a `heap_size` of `1`, the same key twice, and two keys against one count. | +| `heap_rejects_entries_out_of_emitted_order` | Entries must arrive in descending count, ties by key order. | Verifies `i64` keys `[-1, 1]` at a count tie, and counts `[4, 9]`, each fail with a "not in descending count" complaint. | | `cms_heap_rejects_too_many_rows` | The base matrix carries the seed list's row bound. | Verifies a sketch past `MATRIX_MAX_ROWS` fails to serialize, that a crafted envelope of that geometry fails to decode with a message naming `MATRIX_MAX_ROWS`, and that the boundary row count still serializes. | +| `decode_rejects_each_payload_rule` | A `0x03 0x80` payload breaking a cell or heap rule fails to decode. | For a `2x3` `i64` delta with heap size `2`, verifies cells `[0, 5]` beside heap `{1: 9, 2: 4}` decode, and that index `6` and a heap in ascending count each fail. | +| `decode_rejects_another_kind` | A delta decodes only under its own kind_id. | Verifies the `cmsheap_i64_regular_2x3_delta` golden decodes and fails with its kind_id relabelled `0x03 0x00`. | +| `merge_delta_rejects_without_changing_the_state` | A rejected delta leaves the matrix and the heap unchanged. | For a `2x3` state with heap size `2` holding `"kept"`, verifies a `3x2` delta, a delta with heap size `3` and a delta whose last cell overflows `i64::MAX` each fail, leaving the cells and the one-entry heap as they were. | ### CSHeap @@ -317,7 +325,7 @@ Wire tests: [`src/sketches/countsketch_topk/wire.rs`](../src/sketches/countsketc | `zipf_stream_top_k_recall_regular_fast_budget` | Regular path heap achieves high top-k recall on Zipf stream. | On Zipf stream (`rows=5`, `cols=4096`, `top_k=16`, `domain=1024`, `exponent=1.1`, `N=20_000`), verifies heap size bound, entry-count consistency, and recall hits `>= 15` vs truth top-16. | | `zipf_stream_top_k_recall_fast_path_fast_budget` | Fast path heap achieves high top-k recall on Zipf stream. | Runs same Zipf setup in fast mode and verifies heap size bound, entry-count consistency, and recall hits `>= 15`. | | `zipf_stream_regular_fast_heap_overlap` | Regular and fast heaps substantially overlap on Zipf heavy hitters. | On shared Zipf stream (`top_k=16`), verifies key overlap ratio between regular and fast top-k heaps is at least `0.8`. | -| `cs_heap_round_trip_serialization` | The envelope frames a sketch under kind_id `0x0a 0x00`, and the bytes are stable across a round trip. | For a `3x8` regular-path `i64` sketch with a heap of `4` holding three weighted `u64` keys, verifies the bytes open with the ASAPv1 magic, a `kind_id_len` of `2`, and `0x0a 0x00`, that the metadata carries `3x8`, `counter_type` `i64`, `mode` `regular`, `k` `4`, and `key_type` `u64`, that dimensions, counters, heap length and capacity, and per-key estimates and heap counts all match, and that re-serializing reproduces the bytes. | +| `cs_heap_round_trip_serialization` | The envelope frames a sketch under kind_id `0x0a 0x00`, and the bytes are stable across a round trip. | For a `3x8` regular-path `i64` sketch with a heap of `4` holding three weighted `u64` keys, verifies the bytes open with the ASAPv1 magic, a `kind_id_len` of `2`, and `0x0a 0x00`, that the metadata carries `3x8`, `counter_type` `i64`, `mode` `regular`, `heap_size` `4`, and `key_type` `u64`, that dimensions, counters, heap length and capacity, and per-key estimates and heap counts all match, and that re-serializing reproduces the bytes. | | `cs_heap_negative_counters_round_trip` | Signed cells reach the wire and come back unchanged. | Round-trips a `2x4` matrix of alternating positive and negative counters beside a `Str` heap key, and verifies the decoded slice equals the source, still holds a negative cell, keeps the heap key, and re-serializes identically. | | `cs_heap_i32_round_trips_and_is_pinned_by_counter_type` | The `i32` wire config round-trips and its width is identity, not a detail. | Round-trips a `2x4` `Vector2D` sketch, verifies the metadata names `counter_type` `i32`, then that its bytes differ from the numerically equal `i64` sketch's, that `i32` bytes fail to decode as `i64`, and that `i64` bytes fail to decode as `i32`. | | `cs_heap_mode_in_metadata_round_trips` | The metadata `mode` pins which column derivation built the sketch. | Round-trips a `4x16` fast-path sketch, verifies the metadata names `mode` `fast` and the counters are preserved, then that the same bytes fail to decode as a `RegularPath` sketch. | @@ -329,7 +337,7 @@ Wire tests: [`src/sketches/countsketch_topk/wire.rs`](../src/sketches/countsketc | `cs_heap_metadata_rejects_a_missing_key_type_key` | `key_type` is required, so the heap's key variant can never be silently defaulted. | Encodes the other twelve `TopKMetadata` fields as a named map with `key_type` omitted and verifies it does not decode as `TopKMetadata`. | | `cs_heap_rejects_f64_into_an_integer_sketch` | An `f64`-labelled envelope decodes into neither integer sketch. | Wraps metadata naming `counter_type: "f64"` around a valid `2x4` payload and verifies both the `i64` and the `i32` sketch reject it. | | `cs_heap_custom_hasher_profile_round_trips_and_is_self_describing` | The metadata describes the hasher that built the sketch rather than a hardcoded profile. | For a `3x8` sketch with a heap of `4` over a hasher declaring its own `HashProfile`, verifies the counters and both heap entries round-trip, the bytes differ from the standard-profile sketch's over the same keys, and a standard-profile decode rejects them. | -| `cs_heap_refuses_a_k_the_metadata_cannot_carry` | A `k` past the metadata's `u32` field fails the encode rather than truncating. | Verifies a `2x4` sketch built at a heap capacity of `1 << 40` fails to serialize with a message naming the "exceeds the u32 metadata field" rule. | +| `cs_heap_refuses_a_heap_size_the_metadata_cannot_carry` | A `heap_size` past the metadata's `u32` field fails the encode rather than truncating. | Verifies a `2x4` sketch built at a heap capacity of `1 << 40` fails to serialize with a message naming the "exceeds the u32 metadata field" rule. | | `cs_heap_rejects_too_many_rows` | The base matrix carries the seed list's row bound. | Verifies a sketch past `MATRIX_MAX_ROWS` fails to serialize, that a crafted envelope of that geometry fails to decode with a message naming `MATRIX_MAX_ROWS`, and that the boundary row count still serializes. | ### CountL2HH @@ -2000,7 +2008,7 @@ Test file: [`tests/e2e/matrix_instances.rs`](../tests/e2e/matrix_instances.rs) | `csheap_{regular_path_instances_satisfy_the_l2_bound, fast_path_instances_conform_to_the_l2_model}` | Every `CSHeap` instance carries the L2 bound and keeps its heap consistent with the sketch. | One shared body per storage: three `CSHeap` sketches over `zipf_u64(40_000, 4_096, 1.1, 0x10BE_C700)`, whole stream to one and even/odd halves to two `merge`d shards, then `CountSketchSpec::new(rows, cols)` over both - simultaneous `sqrt(kappa/w)*\|\|f_-i\|\|_2` at the union-bounded `kappa` for `delta = 1e-3` with zero tolerance, marginal band at `kappa = 3` rate-pinned - and `assert_heap_matches_sketch` with `cs_heap_count` as the estimate-to-entry map, requiring every heap entry's `count` to equal `cs_heap_count(estimate)` and at most one entry to sit below the true k-th count. Nine instances each: `Vector2D`, `Vector2D`, `DefaultMatrixI32`, `DefaultMatrixI64`, `DefaultMatrixI128` from `Default` at `3x4096`, `FixedMatrix`, `QuickMatrixI64`, `QuickMatrixI128` from `Default` at `5x2048`, and `Vector2D` built explicitly by `CSHeap::new(3, 4096, 32)` because it has no `Default`. Generated as `csheap_regular_path_instances_satisfy_the_l2_bound` (`RegularPath`) and `csheap_fast_path_instances_conform_to_the_l2_model` (`FastPath`). | | `countmin_both_paths_are_exact_on_a_collision_free_workload` | On a collision-free workload both hashing paths return exact counts on every storage backend. | Inserts the eight well-separated keys `1`, `7`, `4_242`, `90_210`, `1_000_003`, `2_147_483_647`, `4_294_967_311`, `9_007_199_254_740_993` at multiplicities `10, 20, ..., 80` into a `RegularPath` and a `FastPath` `CountMin` over the same storage, and requires both `estimate` calls to equal the true count exactly for every key, with zero violations. Run over all ten storages: `Vector2D`, `Vector2D`, `Vector2D`, `Vector2D`, `FixedMatrix`, `DefaultMatrixI32`, `QuickMatrixI64`, `QuickMatrixI128`, `DefaultMatrixI64`, `DefaultMatrixI128`. | | `countsketch_both_paths_are_exact_on_a_collision_free_workload` | The signed family is likewise exact on both paths when nothing collides. | Same eight keys at multiplicities `10, 20, ..., 80` into a `RegularPath` and a `FastPath` `Count` over the same storage, requiring both `estimate` values to equal the true count exactly with zero violations, over nine storages: `Vector2D`, `Vector2D`, `Vector2D`, `FixedMatrix`, `DefaultMatrixI32`, `QuickMatrixI64`, `QuickMatrixI128`, `DefaultMatrixI64`, `DefaultMatrixI128`. | -| `cmsheap_inert_instances_construct_and_report_their_geometry` | The `CMSHeap` instances whose counter type fails the insert bound still construct and answer their geometry. | For each of eight instances, verifies `rows()`, `cols()`, `cms().rows()` and an empty `heap()`: `CMSHeap::, _>::new(3, 4096, 32)` and `CMSHeap::, _>::new(3, 4096, 32)` on both paths at `3x4096`, `CMSHeap::::default()` on both paths at `5x2048`, and `CMSHeap::::default()` on both paths at `3x4096`. This is constructibility only: the test does not assert that `insert`, `insert_many`, `estimate` or `merge` are uncallable, and adding such an impl would leave every assertion here passing. | +| `cmsheap_inert_instances_construct_and_report_their_geometry` | The `CMSHeap` instances whose counter type fails the insert bound still construct and answer their geometry. | For each of six instances, verifies `rows()`, `cols()`, `cms().rows()` and an empty `heap()`: `CMSHeap::, _>::new(3, 4096, 32)` on both paths at `3x4096`, `CMSHeap::::default()` on both paths at `5x2048`, and `CMSHeap::::default()` on both paths at `3x4096`. This is constructibility only: the test does not assert that `insert`, `insert_many`, `estimate` or `merge` are uncallable, and adding such an impl would leave every assertion here passing. | | `csheap_i128_counters_saturate_into_the_heap_instead_of_wrapping` | An `i128` `CSHeap` holds mass past `i64::MAX` while its heap entry saturates at the ceiling. | For each of six instances, inserts `insert_many(U64(0xC0FF_EE01), 1 << 40)` and requires `estimate` to equal `2^40` exactly and the heap entry to carry the same `i64`; then, on a fresh sketch, `insert_many` of `i64::MAX as i128 * 4` and requires `estimate >= i64::MAX as f64`, `cs_heap_count(estimate) == i64::MAX`, and the heap entry to read exactly `i64::MAX` rather than a wrapped negative; then merges a second sketch carrying the same overflow weight and requires the heap entry to stay at `i64::MAX`. Instances: `CSHeap::, _>::new(3, 4096, 32)` on both paths, and `QuickMatrixI128` and `DefaultMatrixI128` from `Default` on both paths. | | `countmin_counter_widths_carry_the_mass_their_type_allows` | Each `CountMin` counter width carries a count the next narrower one cannot. | On `3x64` `RegularPath` sketches keyed by `U64(0xFEED_FACE)`: `Vector2D` returns `i32::MAX - 1` after `insert_many` of that weight; `Vector2D` returns `i32::MAX as i64 * 4`; `Vector2D` returns `i64::MAX as i128 * 4`. Then merges two `Vector2D` `3x64` sketches each holding `i64::MAX` and requires the result to be exactly `i64::MAX as i128 * 2`. | | `countsketch_counter_widths_carry_signed_mass_in_both_directions` | Each `Count` counter width reaches the negative end of its range without wrapping. | On `3x64` `Vector2D<_>` `RegularPath` sketches keyed by `U64(0xDEAD_BEEF)`: `i32` cancels `+i32::MAX/2` against `-(i32::MAX/2)` to exactly `0.0`; `i64` reads `i32::MAX as i64 * 4` and, after a further `-2x` that weight, exactly its negation; `i128` reads `i64::MAX as i128 * 4` and, after a further `-2x`, exactly its negation. | @@ -2143,12 +2151,17 @@ Test file: [`tests/asapv1_golden.rs`](../tests/asapv1_golden.rs) | --- | --- | --- | | `cms_f64_fast_2x3_matches_golden` | A known f64 fast-path Count-Min matrix serializes to its checked-in golden bytes. | Verifies `CountMin::, FastPath>::from_storage` over the `2x3` matrix `[[0.0, 1.5, 2.25], [3.75, 4.125, 5.0625]]` serializes to `asapv1_golden/cms_f64_fast_2x3.hex`, and that decoding that fixture recovers the six cells in row-major order and re-serializes to the same bytes. | | `cms_i64_regular_2x3_matches_golden` | A known i64 regular-path Count-Min matrix serializes to its checked-in golden bytes. | Verifies `CountMin::, RegularPath>::from_storage` over the `2x3` matrix `[[0, 1, 127], [128, 300, 65536]]` serializes to `asapv1_golden/cms_i64_regular_2x3.hex`, and that decoding the fixture reports `rows()` `2`, `cols()` `3`, the six cells in row-major order, and a byte-identical re-serialization. | +| `cmsheap_f64_fast_2x3_f64keys_matches_golden` | `f64` heap counts order by `total_cmp`, a tie by the key's bits. | Verifies an `f64` fast-path `CMSHeap` over the `2x3` f64 matrix with heap size `6` holding `f64` keys `{2.0: +inf, 1.5: 300.25, -0.5: 300.25, 8.0: 0.0, 7.0: -0.0, 9.0: -inf}` serializes to `asapv1_golden/cmsheap_f64_fast_2x3_f64keys.hex`, and that the decode holds that matrix and those entries and re-serializes identically. | +| `cmsheap_i32_fast_2x3_i64keys_matches_golden` | An `i32` fast-path CMSHeap with `i64` keys serializes to its golden bytes. | Verifies the `2x3` Count-Min matrix cast to `i32` with heap size `3` holding `{4294967296: 3, -1: 7, -129: 7}` serializes to `asapv1_golden/cmsheap_i32_fast_2x3_i64keys.hex`, and that the decode holds that matrix and those entries and re-serializes identically. | +| `cmsheap_i64_regular_2x3_delta_matches_golden` | A known CMSHeap delta serializes to its golden bytes and, merged, replaces the state's heap. | Verifies `CMSHeapDelta::::new(2, 3, [0, 1, 2, 4, 5], [127, -1, -33, -32769, 4294967296], heap)` with heap size `5` holding `i64` keys `{7: 128, -129: 128, -1: 4295032832}` serializes to `asapv1_golden/cmsheap_i64_regular_2x3_delta.hex`, that the fixture decodes and re-serializes identically, and that merged into the `string`-keyed `cmsheap_i64_regular_2x3_strkeys` it gives `[127, 0, 94, 128, -32469, 4295032832]` and exactly the delta's heap. | +| `cmsheap_i64_regular_2x3_{strkeys, i64tie, strtie, empty}_matches_golden` | The `i64` regular-path CMSHeap fixtures serialize to their golden bytes. | For each, verifies the `2x3` Count-Min matrix with the fixture's heap (string keys at heap size `5`; an `i64` five-way set with a four-way tie; a string five-way set with a four-way tie; empty at heap size `4`) seated out of emitted order serializes to `asapv1_golden/cmsheap_i64_regular_2x3_.hex`, and that the decode holds that matrix and those entries and re-serializes identically. | | `cs_f64_fast_2x4_delta_matches_golden` | A known f64 fast-path Count Sketch delta serializes to its golden bytes and merges into its state golden. | Verifies `CountSketchDelta::::new(2, 4, [1, 3, 5, 7], [-0.75, 2.25, 0.5, 1e300])` serializes to `asapv1_golden/cs_f64_fast_2x4_delta.hex`, that the fixture decodes and re-serializes identically, and that merged into `cs_f64_fast_2x4` it gives `[0.0, -0.75, 1.5, 0.0, 127.0, -32768.0, 0.125, 0.0]`. | | `cs_f64_fast_2x4_matches_golden` | A known f64 Count Sketch matrix serializes to its golden bytes. | Verifies `Count::, FastPath>::from_storage` over `[[0.0, -0.0, 1.5, -2.25], [127.0, -32768.5, 0.125, -1e300]]` serializes to `asapv1_golden/cs_f64_fast_2x4.hex`, and that decoding it recovers the eight cells and re-serializes identically. | | `cs_i32_regular_2x4_matches_golden` | The i32 counter type reaches the metadata, and an i32 golden is not an i64 sketch. | Verifies `Count::, RegularPath>::from_storage` over `[[0, 127, 128, 65536], [-1, -33, -32768, -2147483648]]` cast to `i32` serializes to `asapv1_golden/cs_i32_regular_2x4.hex`, that decoding it recovers the eight cells and re-serializes identically, that the i32 and i64 regular fixtures have equal length yet differ, and that the i32 fixture fails to decode as `Count, RegularPath>`. | | `cs_i64_fast_2x4_matches_golden` | The fast-path mode reaches the bytes, so the same matrix encodes differently per path. | Verifies `Count::, FastPath>::from_storage` over `[[0, 127, 128, 65536], [-1, -33, -32768, -2147483648]]` serializes to `asapv1_golden/cs_i64_fast_2x4.hex`, that decoding it recovers the eight cells and re-serializes identically, and that the fixture differs from `cs_i64_regular_2x4.hex` built from the same matrix. | | `cs_i64_regular_2x4_delta_matches_golden` | A known i64 Count Sketch delta serializes to its golden bytes and merges into its state golden. | Verifies `CountSketchDelta::::new(2, 4, [0, 1, 2, 4, 6, 7], [-32, -128, 255, -129, 65535, -2147483649])` serializes to `asapv1_golden/cs_i64_regular_2x4_delta.hex`, that the fixture decodes and re-serializes identically, and that merged into `cs_i64_regular_2x4` it gives `[-32, -1, 383, 65536, -130, -33, 32767, -4294967297]`. | | `cs_i64_regular_2x4_matches_golden` | A known signed Count-Sketch matrix serializes to its checked-in golden bytes. | Verifies `Count::, RegularPath>::from_storage` over `[[0, 127, 128, 65536], [-1, -33, -32768, -2147483648]]` — sweeping the msgpack integer widths in both directions — serializes to `asapv1_golden/cs_i64_regular_2x4.hex`, and that decoding the fixture reports `rows()` `2`, `cols()` `4`, the eight cells, and a byte-identical re-serialization. | +| `csheap_i64_regular_2x4_strkeys_matches_golden` | A CSHeap serializes to its golden bytes. | Verifies the `2x4` Count Sketch matrix with heap size `5` holding `{"alpha": 4294967296, "beta": 127, "delta": 127, "gamma": -33}` serializes to `asapv1_golden/csheap_i64_regular_2x4_strkeys.hex`, and that the decode holds that matrix and those entries and re-serializes identically. | | `hll_classic_p12_matches_golden` | A known P12 Classic register pattern serializes to its golden bytes on both the portable and native paths. | Builds `HllSketch::from_raw(HllVariant::Regular, 12, regs, 0.0, 0.0, 0.0)` where `regs` is 4,096 zeros with `[0] = 1`, `[1] = 7`, `[100] = 42` and `[4095] = 3`, verifies `to_msgpack()` equals `asapv1_golden/hll_classic_p12.hex`, that `HyperLogLogP12::::deserialize_from_bytes` on the fixture recovers those registers and re-serializes identically, and that `HllSketch::from_msgpack` reports the same registers, `precision` `12` and `HllVariant::Regular`. | | `hll_ertl_mle_p12_matches_golden` | The Datafusion variant tag reaches the bytes for the same P12 register pattern. | Builds `HllSketch::from_raw(HllVariant::Datafusion, 12, regs, 0.0, 0.0, 0.0)` over the shared P12 pattern (`[0] = 1`, `[1] = 7`, `[100] = 42`, `[4095] = 3` in 4,096 registers), verifies `to_msgpack()` equals `asapv1_golden/hll_ertl_mle_p12.hex`, that `HyperLogLogP12::::deserialize_from_bytes` recovers the registers and re-serializes identically, and that `HllSketch::from_msgpack` reports `HllVariant::Datafusion`. | | `hll_hip_p12_matches_golden` | The HIP variant's three running scalars travel on the wire beside its registers. | Builds `HllSketch::from_raw(HllVariant::Hip, 12, regs, 1.5, 2.5, 3.0)` over the shared P12 pattern, verifies `to_msgpack()` equals `asapv1_golden/hll_hip_p12.hex`, that `HyperLogLogHIPP12::deserialize_from_bytes` re-serializes the fixture identically, and that `HllSketch::from_msgpack` reports the registers, `HllVariant::Hip`, `hip_kxq0` `1.5`, `hip_kxq1` `2.5` and `hip_est` `3.0`. | diff --git a/src/common/heap.rs b/src/common/heap.rs index 74ad00cf..54f2d58b 100644 --- a/src/common/heap.rs +++ b/src/common/heap.rs @@ -9,9 +9,53 @@ use crate::common::input::HHItem; use crate::common::structures::heap::PREALLOCATED_SLOTS; use crate::common::{CommonHeap, DigestBuildHasher, KeepSmallest}; use crate::{DataInput, HeapItem, hash_item64_seeded, hash64_seeded, input_to_owned}; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; use smallvec::SmallVec; +use std::cmp::Ordering; use std::collections::HashMap; +use std::fmt; + +/// The count an [`HHItem`] carries: `i64`, or `f64` ranked by +/// [`f64::total_cmp`]. +pub trait HeapCount: Copy + fmt::Display + Serialize + DeserializeOwned { + /// The total order the heap ranks counts by. + fn order(&self, other: &Self) -> Ordering; +} + +impl HeapCount for i64 { + fn order(&self, other: &Self) -> Ordering { + self.cmp(other) + } +} + +impl HeapCount for f64 { + fn order(&self, other: &Self) -> Ordering { + self.total_cmp(other) + } +} + +/// A matrix counter type with a top-k heap beside it: `f64` counters keep `f64` +/// heap counts, the integer counters keep `i64`. +pub trait TopKCounter { + /// The heap's count type. + type HeapCount: HeapCount; +} + +impl TopKCounter for i32 { + type HeapCount = i64; +} + +impl TopKCounter for i64 { + type HeapCount = i64; +} + +impl TopKCounter for i128 { + type HeapCount = i64; +} + +impl TopKCounter for f64 { + type HeapCount = f64; +} /// Heap positions sharing one digest. Two residents colliding on a 64-bit /// digest is rare enough that the inline pair is effectively never spilled. @@ -22,17 +66,18 @@ type Index = HashMap; /// Serialized form. The index is derived from the heap, so it is rebuilt on /// load rather than carried. #[derive(Deserialize)] -struct HHHeapState { - heap: CommonHeap, +#[serde(bound = "")] +struct HHHeapState { + heap: CommonHeap, KeepSmallest>, k: usize, } /// Bounded min-heap over [`HHItem`] with key lookup, retaining the `k` largest /// counts. #[derive(Serialize, Deserialize, Clone, Debug)] -#[serde(from = "HHHeapState")] -pub struct HHHeap { - heap: CommonHeap, +#[serde(from = "HHHeapState", bound = "")] +pub struct HHHeap { + heap: CommonHeap, KeepSmallest>, /// Digest of each resident, parallel to the heap array, so a sift patches /// the index without re-hashing the keys it moves. #[serde(skip)] @@ -42,8 +87,8 @@ pub struct HHHeap { k: usize, } -impl From for HHHeap { - fn from(state: HHHeapState) -> Self { +impl From> for HHHeap { + fn from(state: HHHeapState) -> Self { let mut heap = HHHeap { heap: state.heap, slots: Vec::new(), @@ -55,7 +100,7 @@ impl From for HHHeap { } } -impl HHHeap { +impl HHHeap { /// Creates a new HHHeap with capacity k. pub fn new(k: usize) -> Self { Self::with_expected_len(k, k) @@ -92,7 +137,7 @@ impl HHHeap { /// /// Returns whether the key took its place without displacing another: true /// for one already resident, and for a new one the heap had room for. - pub fn update(&mut self, key: &DataInput, count: i64) -> bool { + pub fn update(&mut self, key: &DataInput, count: C) -> bool { let slot = self.slot_for_input(key); if let Some(idx) = self.lookup(slot, |item| item.key == *key) { self.rescore(idx, count); @@ -110,7 +155,7 @@ impl HHHeap { /// Updates an existing owned item or inserts it if needed. /// /// The return value means what [`Self::update`]'s does. - pub fn update_heap_item(&mut self, key: &HeapItem, count: i64) -> bool { + pub fn update_heap_item(&mut self, key: &HeapItem, count: C) -> bool { let slot = self.slot_for_item(key); if let Some(idx) = self.lookup(slot, |item| &item.key == key) { self.rescore(idx, count); @@ -127,7 +172,7 @@ impl HHHeap { /// Provides access to the underlying data as a slice. /// Named `heap` for API compatibility with TopKHeap. - pub fn heap(&self) -> &[HHItem] { + pub fn heap(&self) -> &[HHItem] { self.heap.as_slice() } @@ -158,7 +203,7 @@ impl HHHeap { } /// Creates a copy of another HHHeap. - pub fn from_heap(other: &HHHeap) -> Self { + pub fn from_heap(other: &HHHeap) -> Self { other.clone() } @@ -169,7 +214,7 @@ impl HHHeap { // -- index maintenance --------------------------------------------------- - fn lookup(&self, slot: u64, matches: impl Fn(&HHItem) -> bool) -> Option { + fn lookup(&self, slot: u64, matches: impl Fn(&HHItem) -> bool) -> Option { self.positions .get(&slot)? .iter() @@ -178,7 +223,7 @@ impl HHHeap { } /// Writes a new count onto a resident and re-sifts it. - fn rescore(&mut self, idx: usize, count: i64) { + fn rescore(&mut self, idx: usize, count: C) { self.heap[idx].count = count; let Self { heap, @@ -191,7 +236,7 @@ impl HHHeap { /// Places a key the heap has decided to accept, evicting the root when the /// heap is already at capacity. - fn seat(&mut self, key: HeapItem, slot: u64, count: i64) { + fn seat(&mut self, key: HeapItem, slot: u64, count: C) { let item = HHItem::create_item(key, count); if self.heap.len() < self.k { let idx = self.heap.len(); @@ -238,7 +283,7 @@ impl HHHeap { } #[inline] - fn should_accept_new(&self, count: i64) -> bool { + fn should_accept_new(&self, count: C) -> bool { if self.k == 0 { return false; } @@ -251,7 +296,7 @@ impl HHHeap { ); self.heap .peek() - .is_some_and(|min_item| count > min_item.count) + .is_some_and(|min_item| count.order(&min_item.count).is_gt()) } #[inline] diff --git a/src/common/input.rs b/src/common/input.rs index b297d602..12ee030c 100644 --- a/src/common/input.rs +++ b/src/common/input.rs @@ -8,8 +8,8 @@ use std::{ }; use crate::{ - Count, CountL2HH, CountMin, ErtlMLE, FastPath, HyperLogLog, KLL, MatrixHashType, UnivMon, - Vector2D, + Count, CountL2HH, CountMin, ErtlMLE, FastPath, HeapCount, HyperLogLog, KLL, MatrixHashType, + UnivMon, Vector2D, }; /// Input wrapper for sketch APIs (supports primitive and borrowed values). @@ -551,16 +551,17 @@ impl HydraCounter { /// A key-count pair used in heap-based sketches for tracking heavy hitters. #[derive(Serialize, Deserialize, Clone, Debug)] -pub struct HHItem { +#[serde(bound = "")] +pub struct HHItem { /// Stored key. pub key: HeapItem, /// Estimated count associated with `key`. - pub count: i64, + pub count: C, } -impl HHItem { +impl HHItem { /// Creates a new Item with the given key and count. - pub fn new(k: DataInput, count: i64) -> Self { + pub fn new(k: DataInput, count: C) -> Self { HHItem { key: input_to_owned(&k), count, @@ -568,12 +569,12 @@ impl HHItem { } /// Creates an item from an already-owned key. - pub fn create_item(k: HeapItem, count: i64) -> Self { + pub fn create_item(k: HeapItem, count: C) -> Self { HHItem { key: k, count } } /// Legacy constructor for compatibility. - pub fn init_item(k: DataInput, count: i64) -> Self { + pub fn init_item(k: DataInput, count: C) -> Self { HHItem { key: input_to_owned(&k), count, @@ -587,25 +588,25 @@ impl HHItem { } // Implement Ord and PartialOrd to compare by count -impl Ord for HHItem { +impl Ord for HHItem { fn cmp(&self, other: &Self) -> std::cmp::Ordering { - self.count.cmp(&other.count) + self.count.order(&other.count) } } -impl PartialOrd for HHItem { +impl PartialOrd for HHItem { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } -impl PartialEq for HHItem { +impl PartialEq for HHItem { fn eq(&self, other: &Self) -> bool { - self.key == other.key && self.count == other.count + self.key == other.key && self.count.order(&other.count).is_eq() } } -impl Eq for HHItem {} +impl Eq for HHItem {} #[cfg(test)] mod tests { diff --git a/src/common/mod.rs b/src/common/mod.rs index bbdce67f..a3040a69 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -41,7 +41,7 @@ pub use hash::{ hash_for_matrix_seeded_with_mode_generic, hash_item64_seeded, hash_item128_seeded, hash_mode_for_matrix, hash64_seeded, hash128_seeded, }; -pub use heap::HHHeap; +pub use heap::{HHHeap, HeapCount, TopKCounter}; pub use input::{DataInput, HHItem, HeapItem, L2HH, heap_item_to_sketch_input, input_to_owned}; pub use numerical::NumericalValue; pub use precompute_hash::PRECOMPUTED_HASH; diff --git a/src/message_pack_format/envelope.rs b/src/message_pack_format/envelope.rs index cfe0b18d..615fa54b 100644 --- a/src/message_pack_format/envelope.rs +++ b/src/message_pack_format/envelope.rs @@ -221,6 +221,9 @@ mod tests { "../../asapv1_golden/cs_i64_regular_2x4_delta.hex" )); let kll = hex(include_str!("../../asapv1_golden/kll_f64_k200.hex")); + let cms_heap_delta = hex(include_str!( + "../../asapv1_golden/cmsheap_i64_regular_2x3_delta.hex" + )); let str_keys = ["alpha", "beta", "gamma", "alpha", "alpha"].map(DataInput::Str); let bytes_keys = [b"ab", b"cd", b"ab"].map(|key| DataInput::Bytes(key)); @@ -304,6 +307,7 @@ mod tests { sketch!("KLL dynamic", kll_dynamic, KLLDynamic), sketch!("CMSHeap", cms_heap, CmsHeap), sketch!("CMSHeap bytes", cms_heap_bytes, CmsHeap), + case!("CMSHeap delta", cms_heap_delta, CMSHeapDelta), sketch!("CSHeap", cs_heap, CsHeap), sketch!("CountL2HH", l2hh, CountL2HH), sketch!("DDSketch", positive, DDSketch), diff --git a/src/message_pack_format/wire_key.rs b/src/message_pack_format/wire_key.rs index 68c40168..37b4f354 100644 --- a/src/message_pack_format/wire_key.rs +++ b/src/message_pack_format/wire_key.rs @@ -339,8 +339,13 @@ mod tests { for value in [1.0, 2.5, 7.0] { kll.update(&value); } - heap.heap_mut().update(&DataInput::F32(1.5), 11); - heap.heap_mut().update(&DataInput::F32(3.0), 5); + heap.heap_mut().update(&DataInput::F32(1.5), 11.0); + heap.heap_mut().update(&DataInput::F32(3.0), 5.0); + let mut delta_heap = HHHeap::new(4); + delta_heap.update(&DataInput::F32(1.5), 13.0); + let heap_delta = + CMSHeapDelta::::new(2, 4, vec![1], vec![2.5], delta_heap) + .expect("delta"); space_saving.insert(&DataInput::F64(2.5)); univmon.insert(&DataInput::F64(2.5), 2); hydra @@ -360,6 +365,7 @@ mod tests { )); let kll = &kll.serialize_to_bytes().unwrap(); let heap = &heap.serialize_to_bytes().unwrap(); + let heap_delta = &heap_delta.serialize_to_bytes().unwrap(); let ss = &space_saving.serialize_to_bytes().unwrap(); let um = &univmon.serialize_to_bytes().unwrap(); let hy = &hydra.serialize_to_bytes().unwrap(); @@ -374,6 +380,8 @@ mod tests { }; let kll_ok = |b: &[u8]| KLL::::deserialize_from_bytes(b).is_ok(); let heap_ok = |b: &[u8]| HeapF64::deserialize_from_bytes(b).is_ok(); + let heap_delta_ok = + |b: &[u8]| CMSHeapDelta::::deserialize_from_bytes(b).is_ok(); let ss_ok = |b: &[u8]| SpaceSaving::::deserialize_from_bytes(b).is_ok(); let um_ok = |b: &[u8]| UnivMon::deserialize_from_bytes(b).is_ok(); let hy_ok = |b: &[u8]| Hydra::deserialize_from_bytes(b).is_ok(); @@ -396,6 +404,16 @@ mod tests { row("KLL f64 item", kll, 7.0, Int, kll_ok), row("CMSHeap f64 count", heap, 1.0, Int, heap_ok), row("CMSHeap f32 key", heap, 1.5, F64, heap_ok), + row("CMSHeap f64 heap count", heap, 11.0, Int, heap_ok), + row("CMSHeap f64 delta", heap_delta, 2.5, Int, heap_delta_ok), + row("CMSHeap delta f32 key", heap_delta, 1.5, F64, heap_delta_ok), + row( + "CMSHeap delta f64 heap count", + heap_delta, + 13.0, + Int, + heap_delta_ok, + ), row("SpaceSaving f64 key", ss, 2.5, F32, ss_ok), row("UnivMon f64 key", um, 2.5, F32, um_ok), row("Hydra KLL item", hy, 7.0, Int, hy_ok), diff --git a/src/sketches/countminsketch_topk.rs b/src/sketches/countminsketch_topk.rs index c8a82f68..d653c37c 100644 --- a/src/sketches/countminsketch_topk.rs +++ b/src/sketches/countminsketch_topk.rs @@ -7,32 +7,41 @@ use crate::{ CountMin, DataInput, DefaultMatrixI32, DefaultMatrixI64, DefaultMatrixI128, DefaultXxHasher, FastPath, FixedMatrix, HHHeap, MatrixStorage, QuickMatrixI64, QuickMatrixI128, RegularPath, - SketchHasher, Vector2D, heap_item_to_sketch_input, + SketchHasher, TopKCounter, Vector2D, heap_item_to_sketch_input, }; +mod delta; pub(crate) mod heap_wire; mod wire; +pub use delta::CMSHeapDelta; + +/// The heap count type of a `CMSHeap` over storage `S`. +type HeapCountOf = <::Counter as TopKCounter>::HeapCount; + const DEFAULT_TOP_K: usize = 32; /// A Count-Min Sketch paired with a top-k heavy-hitter heap. /// -/// Generic over the same type parameters as [`CountMin`]. +/// Generic over the same type parameters as [`CountMin`]. The heap's counts are +/// `f64` over `f64` counters and `i64` otherwise ([`TopKCounter`]). #[derive(Clone)] pub struct CMSHeap< S: MatrixStorage = Vector2D, Mode = RegularPath, H: SketchHasher = DefaultXxHasher, -> { +> where + S::Counter: TopKCounter, +{ cms: CountMin, - heap: HHHeap, + heap: HHHeap>, } // -- Construction for Vector2D-backed storage -------------------------------- impl CMSHeap, M, H> where - T: Copy + Default + std::ops::AddAssign, + T: Copy + Default + std::ops::AddAssign + TopKCounter, { /// Creates a new `CMSHeap` with the given CMS dimensions and heap capacity. pub fn new(rows: usize, cols: usize, top_k: usize) -> Self { @@ -45,7 +54,10 @@ where // -- Construction from any MatrixStorage ------------------------------------- -impl CMSHeap { +impl CMSHeap +where + S::Counter: TopKCounter, +{ /// Creates a `CMSHeap` from a pre-built storage backend. pub fn from_storage(storage: S, top_k: usize) -> Self { CMSHeap { @@ -159,7 +171,10 @@ impl Default for CMSHeap { // -- Shared accessors (all storage types) ------------------------------------ -impl CMSHeap { +impl CMSHeap +where + S::Counter: TopKCounter, +{ /// Returns a reference to the internal CMS. pub fn cms(&self) -> &CountMin { &self.cms @@ -171,12 +186,12 @@ impl CMSHeap { } /// Returns a reference to the heavy-hitter heap. - pub fn heap(&self) -> &HHHeap { + pub fn heap(&self) -> &HHHeap> { &self.heap } /// Returns a mutable reference to the heavy-hitter heap. - pub fn heap_mut(&mut self) -> &mut HHHeap { + pub fn heap_mut(&mut self) -> &mut HHHeap> { &mut self.heap } @@ -202,7 +217,8 @@ impl CMSHeap { impl CMSHeap where - S::Counter: Copy + Ord + From + Into + std::ops::AddAssign, + S::Counter: Copy + PartialOrd + From + std::ops::AddAssign + TopKCounter, + S::Counter: Into>, { /// Inserts a single observation and updates the top-k heap. #[inline] @@ -260,7 +276,8 @@ where impl CMSHeap where S: MatrixStorage + crate::FastPathHasher, - S::Counter: Copy + Ord + From + Into + std::ops::AddAssign, + S::Counter: Copy + PartialOrd + From + std::ops::AddAssign + TopKCounter, + S::Counter: Into>, { /// Inserts a single observation using fast-path hashing and updates the heap. #[inline] @@ -317,7 +334,7 @@ mod tests { use crate::test_utils::sample_zipf_u64; use std::collections::{HashMap, HashSet}; - fn heap_count_for_key(heap: &HHHeap, key: &DataInput) -> Option { + fn heap_count_for_key(heap: &HHHeap, key: &DataInput) -> Option { heap.heap() .iter() .find(|item| heap_item_to_sketch_input(&item.key) == *key) @@ -444,6 +461,24 @@ mod tests { assert_eq!(heap_item.count, 30); } + /// An `f64` sketch's heap holds the unrounded estimate after an insert and + /// after a merge, on both paths. + #[test] + fn f64_heap_counts_are_the_unrounded_estimate() { + let key = DataInput::Str("flow"); + let mut regular = CMSHeap::, RegularPath>::new(3, 64, 4); + regular.insert_many(&key, 2.25); + assert_eq!(heap_count_for_key(regular.heap(), &key), Some(2.25)); + regular.merge(®ular.clone()); + assert_eq!(heap_count_for_key(regular.heap(), &key), Some(4.5)); + + let mut fast = CMSHeap::, FastPath>::new(3, 64, 4); + fast.insert_many(&key, 2.25); + assert_eq!(heap_count_for_key(fast.heap(), &key), Some(2.25)); + fast.merge(&fast.clone()); + assert_eq!(heap_count_for_key(fast.heap(), &key), Some(4.5)); + } + #[test] fn insert_many_updates_estimate_and_heap() { // Verifies batched insert_many updates both estimate and heap entry count. diff --git a/src/sketches/countminsketch_topk/delta.rs b/src/sketches/countminsketch_topk/delta.rs new file mode 100644 index 00000000..32930e4b --- /dev/null +++ b/src/sketches/countminsketch_topk/delta.rs @@ -0,0 +1,210 @@ +//! The CMSHeap delta, ASAPv1 kind `0x03 0x80` (`docs/asapv1_wire_format.md` +//! §3.23). Its metadata is the state's (§3.7); its payload is +//! `[indices, deltas, keys, heap_counts]`: the changed cells and the producer's +//! whole heap. + +use std::marker::PhantomData; + +use rmp_serde::{decode::Error as RmpDecodeError, encode::Error as RmpEncodeError}; +use serde::{Deserialize, Serialize}; + +use crate::message_pack_format::delta::{add_cells, check_cells}; +use crate::message_pack_format::envelope; +use crate::sketches::countminsketch::{CmsWireCounter, CmsWireMode}; +use crate::{ + DefaultXxHasher, HHHeap, HashProfile, RegularPath, SketchHasher, TopKCounter, Vector2D, +}; + +use super::CMSHeap; +use super::heap_wire::{ + DeltaCells, check_geometry, decode_metadata, decode_payload, encode_metadata, encode_payload, + heap_entries, rebuild_heap, wire_key_type, +}; + +/// CMSHeap delta kind_id: the state's `0x03 0x00` with the variant's top bit set. +const CMS_HEAP_DELTA_KIND: &[u8] = &[0x03, 0x80]; + +/// A change to a `rows x cols` [`CMSHeap`] state: each value of `deltas` is +/// added to the cell its position names in `indices`, a row-major index +/// `row * cols + col`, and `heap` replaces the state's heap. Indices are +/// strictly ascending and below `rows * cols`, and no delta is zero. +#[derive(Clone, Debug)] +pub struct CMSHeapDelta { + rows: usize, + cols: usize, + indices: Vec, + deltas: Vec, + heap: HHHeap, + _config: PhantomData<(Mode, H)>, +} + +impl CMSHeapDelta +where + T: CmsWireCounter + TopKCounter + Serialize + for<'de> Deserialize<'de>, + Mode: CmsWireMode, + H: SketchHasher + HashProfile, +{ + /// Builds a delta over a `rows x cols` matrix from the changed cells and the + /// producer's heap. Fails when the geometry is not wire-eligible or the cells + /// break the rules above. + pub fn new( + rows: usize, + cols: usize, + indices: Vec, + deltas: Vec, + heap: HHHeap, + ) -> Result { + check_geometry("CMSHeap", rows, cols)?; + check_cells(&indices, &deltas, ..rows as u64 * cols as u64) + .map_err(|e| format!("CMSHeap delta: {e}"))?; + Ok(Self { + rows, + cols, + indices, + deltas, + heap, + _config: PhantomData, + }) + } + + /// Serializes the delta into an ASAPv1 MessagePack envelope. Fails as the + /// state's encode does on the heap: keys that mix `HeapItem` variants, a + /// 128-bit key, or a `heap_size` past `u32`. + pub fn serialize_to_bytes(&self) -> Result, RmpEncodeError> { + let entries = heap_entries(&self.heap); + let key_type = wire_key_type(&entries)?; + let metadata = encode_metadata::( + "CMSHeap", + self.rows, + self.cols, + T::COUNTER_TYPE, + Mode::MODE, + self.heap.capacity(), + key_type, + )?; + let cells = DeltaCells { + indices: self.indices.clone(), + deltas: self.deltas.clone(), + }; + let payload = encode_payload(key_type, cells, &entries)?; + Ok(envelope::encode(CMS_HEAP_DELTA_KIND, &metadata, &payload)) + } + + /// Deserializes a delta from an ASAPv1 MessagePack envelope. + pub fn deserialize_from_bytes(bytes: &[u8]) -> Result { + let (kind_id, metadata, payload) = + envelope::split(bytes).map_err(RmpDecodeError::Uncategorized)?; + if kind_id != CMS_HEAP_DELTA_KIND { + return Err(RmpDecodeError::Uncategorized(format!( + "CMSHeap delta kind_id mismatch: stored {kind_id:?}, expected {CMS_HEAP_DELTA_KIND:?}" + ))); + } + let shape = decode_metadata::("CMSHeap", metadata, T::COUNTER_TYPE, Mode::MODE)?; + let (cells, entries) = decode_payload::, _>(&shape.key_type, payload)?; + let heap = rebuild_heap(shape.heap_size, entries)?; + Self::new(shape.rows, shape.cols, cells.indices, cells.deltas, heap) + .map_err(RmpDecodeError::Uncategorized) + } +} + +impl CMSHeap, Mode, H> +where + T: CmsWireCounter + TopKCounter + std::ops::AddAssign + Serialize + for<'de> Deserialize<'de>, + Mode: CmsWireMode, + H: SketchHasher + HashProfile, +{ + /// Adds a delta's cells to the counters and replaces the heap with the + /// delta's. Fails, changing nothing, when the delta is for another geometry + /// or heap capacity, or an integer add overflows. + pub fn merge_delta(&mut self, delta: &CMSHeapDelta) -> Result<(), String> { + let (rows, cols, heap_size) = (self.rows(), self.cols(), self.heap.capacity()); + if (rows, cols, heap_size) != (delta.rows, delta.cols, delta.heap.capacity()) { + return Err(format!( + "CMSHeap delta: metadata mismatch: a {}x{} delta with heap_size {} for a \ + {rows}x{cols} sketch with heap_size {heap_size}", + delta.rows, + delta.cols, + delta.heap.capacity() + )); + } + add_cells( + self.cms.as_storage_mut().as_mut_slice(), + &delta.indices, + &delta.deltas, + ) + .map_err(|e| format!("CMSHeap delta: {e}"))?; + self.heap = delta.heap.clone(); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message_pack_format::envelope::hex; + use crate::{DataInput, HeapItem}; + + type Delta = CMSHeapDelta; + type Sketch = CMSHeap, RegularPath>; + + const DELTA: &str = include_str!("../../../asapv1_golden/cmsheap_i64_regular_2x3_delta.hex"); + + /// A 2x3 `i64` delta envelope, heap_size 2, around the given payload arrays. + fn delta_bytes(indices: &[u64], keys: &[u64], heap_counts: &[i64]) -> Vec { + let metadata = + encode_metadata::("CMSHeap", 2, 3, "i64", "regular", 2, "u64") + .unwrap(); + let deltas = vec![1i64; indices.len()]; + let payload = rmp_serde::to_vec(&(indices, deltas, keys, heap_counts)).unwrap(); + envelope::encode(CMS_HEAP_DELTA_KIND, &metadata, &payload) + } + + #[test] + fn decode_rejects_each_payload_rule() { + let decodes = |bytes: Vec| Delta::deserialize_from_bytes(&bytes).is_ok(); + assert!(decodes(delta_bytes(&[0, 5], &[1, 2], &[9, 4]))); + assert!(!decodes(delta_bytes(&[6], &[1], &[9])), "index rows * cols"); + assert!( + !decodes(delta_bytes(&[0], &[1, 2], &[4, 9])), + "heap out of order" + ); + } + + #[test] + fn decode_rejects_another_kind() { + let delta = hex(DELTA); + assert!(Delta::deserialize_from_bytes(&delta).is_ok()); + let mut state_kind = delta.clone(); + state_kind[9] = 0x00; + assert!(Delta::deserialize_from_bytes(&state_kind).is_err()); + } + + #[test] + fn merge_delta_rejects_without_changing_the_state() { + let mut heap = HHHeap::new(2); + heap.update(&DataInput::U64(7), 3); + let delta = + |heap: HHHeap, deltas: Vec| Delta::new(2, 3, vec![0, 5], deltas, heap).unwrap(); + let cells = |_, c| if c == 2 { i64::MAX } else { 0 }; + let mut state = Sketch::from_storage(Vector2D::from_fn(2, 3, cells), 2); + state.heap_mut().update(&DataInput::Str("kept"), 1); + + let transposed = Delta::new(3, 2, vec![0, 5], vec![1, -1], HHHeap::new(2)).unwrap(); + let wider = delta(HHHeap::new(3), vec![1, -1]); + let overflowing = delta(heap, vec![1, 1]); + for rejected in [transposed, wider, overflowing] { + assert!(state.merge_delta(&rejected).is_err()); + assert_eq!( + state.cms().as_storage().as_slice(), + &[0, 0, i64::MAX, 0, 0, i64::MAX] + ); + assert_eq!(state.heap().len(), 1); + assert!( + state + .heap() + .find_heap_item(&HeapItem::String("kept".into())) + .is_some() + ); + } + } +} diff --git a/src/sketches/countminsketch_topk/heap_wire.rs b/src/sketches/countminsketch_topk/heap_wire.rs index 02f265f9..0274ea6b 100644 --- a/src/sketches/countminsketch_topk/heap_wire.rs +++ b/src/sketches/countminsketch_topk/heap_wire.rs @@ -4,10 +4,11 @@ //! [`CSHeap`](crate::sketches::countsketch_topk::CSHeap) (kind_id `0x0a 0x00`) //! are each a matrix sketch plus an [`HHHeap`], so both carry the same //! descriptor metadata and the same positional payload -//! `[counts, keys, heap_counts]`. This module is the one place that encoding -//! lives: the metadata DTO, the payload DTO, the `key_type` mapping, the -//! emitted order, and the heap rebuild. See `docs/asapv1_wire_format.md` §3.7 -//! and §3.10 for the pieces it follows. +//! `[counts, keys, heap_counts]`; their deltas carry +//! `[indices, deltas, keys, heap_counts]` under the same metadata. This module +//! is the one place that encoding lives: the metadata DTO, the payload DTOs, the +//! `key_type` mapping, the emitted order, and the heap rebuild. See +//! `docs/asapv1_wire_format.md` §3.7, §3.10 and §3.23 for the pieces it follows. //! //! ## `key_type` names the exact `HeapItem` variant //! @@ -28,26 +29,29 @@ //! ## Emitted order (byte-stable round trips) //! //! The heap's array order follows the sift path and does not survive a rebuild, -//! so the payload is **order-defined**: descending count, ties broken by -//! [`key_order`], a total order over [`HeapItem`] (variant tag first, then the -//! value). Two heaps holding the same entries emit the same bytes whatever -//! order they were seated in, and re-serializing a decoded sketch reproduces -//! its bytes exactly. +//! so the payload is **order-defined**: descending count ([`HeapCount::order`], +//! `f64::total_cmp` for `f64` counts), ties broken by [`key_order`], a total +//! order over [`HeapItem`] (variant tag first, then the value). Two heaps holding +//! the same entries emit the same bytes whatever order they were seated in, and +//! re-serializing a decoded sketch reproduces its bytes exactly. The decoder +//! rejects entries in any other order. //! //! ## What the heap does not put on the wire //! //! `HHHeap`'s `slots` and `positions` are `#[serde(skip)]` and are rebuilt from //! the entries, so no index reaches the wire and no crafted payload can point -//! one out of bounds or into a cycle. `k` is construction config, so it lives -//! in the metadata; it never sizes an allocation on decode. +//! one out of bounds or into a cycle. `heap_size` is construction config, so it +//! lives in the metadata; it never sizes an allocation on decode. use rmp_serde::{decode::Error as RmpDecodeError, encode::Error as RmpEncodeError}; use serde::{Deserialize, Serialize}; +use std::cmp::Ordering; use std::collections::HashSet; +use crate::common::hash::check_matrix_rows; use crate::message_pack_format::envelope; use crate::message_pack_format::wire_key::{WireBytes, exact_vec}; -use crate::{HHHeap, HashProfile, HeapItem}; +use crate::{HHHeap, HashProfile, HeapCount, HeapItem}; /// Metadata `key_type` of a heap that holds nothing. A heap with no entries has /// no variant to report, so the wire pins one: empty heaps have one encoding. @@ -97,6 +101,13 @@ fn key_order(key: &HeapItem) -> (u8, u128, &[u8]) { } } +/// The emitted order of two entries: descending count, ties broken by +/// [`key_order`]. +fn emitted_order(a: (&HeapItem, N), b: (&HeapItem, N)) -> Ordering { + b.1.order(&a.1) + .then_with(|| key_order(a.0).cmp(&key_order(b.0))) +} + /// The complaint both directions report when one key is on the wire twice. const DUPLICATE_KEY: &str = "ASAPv1 top-k heap: the same key appears twice"; @@ -122,7 +133,7 @@ pub(crate) fn check_distinct_keys<'a>( /// with keys in this declaration order — the canonical order the wire spec /// fixes (Go must mirror it). Hash-spec fields first, then the base sketch's /// structural params `rows` / `cols` / `counter_type` / `mode`, then the heap's -/// `k` / `key_type`. +/// `heap_size` / `key_type`. #[derive(Debug, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub(crate) struct TopKMetadata { @@ -137,20 +148,21 @@ pub(crate) struct TopKMetadata { pub(crate) cols: u32, pub(crate) counter_type: String, pub(crate) mode: String, - pub(crate) k: u32, + pub(crate) heap_size: u32, pub(crate) key_type: String, } /// Builds the descriptor metadata from the hasher's [`HashProfile`], so the wire /// bytes truthfully describe how the sketch was hashed. `rows` / `cols` / -/// `counter_type` / `mode` describe the base matrix; `k` is the heap capacity -/// and `key_type` the exact [`HeapItem`] variant the `keys` array carries. +/// `counter_type` / `mode` describe the base matrix; `heap_size` is the heap +/// capacity and `key_type` the exact [`HeapItem`] variant the `keys` array +/// carries. pub(crate) fn topk_metadata( rows: u32, cols: u32, counter_type: &str, mode: &str, - k: u32, + heap_size: u32, key_type: &str, ) -> TopKMetadata { TopKMetadata { @@ -165,18 +177,106 @@ pub(crate) fn topk_metadata( cols, counter_type: counter_type.to_string(), mode: mode.to_string(), - k, + heap_size, key_type: key_type.to_string(), } } +/// The wire-eligible base matrix: `1 <= rows <= MATRIX_MAX_ROWS` and +/// `1 <= cols <= u32::MAX`. +pub(crate) fn check_geometry(sketch: &str, rows: usize, cols: usize) -> Result<(), String> { + if rows == 0 || cols == 0 { + return Err(format!( + "{sketch} dimensions must be non-zero: rows={rows}, cols={cols}" + )); + } + check_matrix_rows(sketch, rows)?; + if u32::try_from(cols).is_err() { + return Err(format!( + "{sketch} cols {cols} exceeds the u32 metadata field" + )); + } + Ok(()) +} + +/// The metadata block of a `sketch` (`"CMSHeap"` / `"CSHeap"`) state or delta. +/// Fails when the geometry is not wire-eligible or `heap_size` overflows the +/// metadata's `u32` field. +pub(crate) fn encode_metadata( + sketch: &str, + rows: usize, + cols: usize, + counter_type: &str, + mode: &str, + heap_size: usize, + key_type: &str, +) -> Result, RmpEncodeError> { + let complain = |e: String| RmpEncodeError::Syntax(format!("ASAPv1 {sketch} envelope: {e}")); + check_geometry(sketch, rows, cols).map_err(complain)?; + let heap_size = u32::try_from(heap_size).map_err(|_| { + complain(format!( + "heap_size {heap_size} exceeds the u32 metadata field" + )) + })?; + rmp_serde::to_vec_named(&topk_metadata::( + rows as u32, + cols as u32, + counter_type, + mode, + heap_size, + key_type, + )) +} + +/// What a top-k metadata block declares beyond the pinned fields. +pub(crate) struct TopKShape { + pub(crate) rows: usize, + pub(crate) cols: usize, + pub(crate) heap_size: usize, + pub(crate) key_type: String, +} + +/// Reads a metadata block. The hash spec, `counter_type` and `mode` must be the +/// target's; `rows` / `cols` / `heap_size` / `key_type` are echoed back, and the +/// geometry must be wire-eligible, checked before anything is sized from it. +pub(crate) fn decode_metadata( + sketch: &str, + block: &[u8], + counter_type: &str, + mode: &str, +) -> Result { + let meta: TopKMetadata = envelope::decode_block(block)?; + if meta + != topk_metadata::( + meta.rows, + meta.cols, + counter_type, + mode, + meta.heap_size, + &meta.key_type, + ) + { + return Err(RmpDecodeError::Uncategorized(format!( + "ASAPv1 {sketch} envelope: metadata mismatch" + ))); + } + let (rows, cols) = (meta.rows as usize, meta.cols as usize); + check_geometry(sketch, rows, cols).map_err(RmpDecodeError::Uncategorized)?; + Ok(TopKShape { + rows, + cols, + heap_size: meta.heap_size as usize, + key_type: meta.key_type, + }) +} + /// Top-k payload, a msgpack **array** (`to_vec`, positional): /// `[counts, keys, heap_counts]`. `counts` is the base matrix packed row-major /// with the element type the metadata `counter_type` fixes; `keys` and /// `heap_counts` are the heap's entries, parallel and equal-length, with `keys` /// typed by the metadata `key_type`. #[derive(Debug, Serialize, Deserialize)] -pub(crate) struct TopKPayload { +pub(crate) struct TopKPayload { #[serde( deserialize_with = "exact_vec", bound(deserialize = "C: Deserialize<'de>") @@ -187,29 +287,128 @@ pub(crate) struct TopKPayload { bound(deserialize = "K: Deserialize<'de>") )] pub(crate) keys: Vec, - pub(crate) heap_counts: Vec, + #[serde( + deserialize_with = "exact_vec", + bound(deserialize = "N: Deserialize<'de>") + )] + pub(crate) heap_counts: Vec, +} + +/// Top-k delta payload (§3.23), a msgpack **array**: +/// `[indices, deltas, keys, heap_counts]`. `indices` / `deltas` are the changed +/// cells; `keys` / `heap_counts` are the producer's whole heap, as in +/// [`TopKPayload`]. +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct TopKDeltaPayload { + pub(crate) indices: Vec, + #[serde( + deserialize_with = "exact_vec", + bound(deserialize = "C: Deserialize<'de>") + )] + pub(crate) deltas: Vec, + #[serde( + deserialize_with = "exact_vec", + bound(deserialize = "K: Deserialize<'de>") + )] + pub(crate) keys: Vec, + #[serde( + deserialize_with = "exact_vec", + bound(deserialize = "N: Deserialize<'de>") + )] + pub(crate) heap_counts: Vec, +} + +/// The matrix part of a top-k payload, written ahead of the heap's `keys` and +/// `heap_counts`: a state's `counts`, or a delta's [`DeltaCells`]. +pub(crate) trait TopKBody: Sized { + /// Packs `self` and the heap arrays into one payload. + fn encode( + self, + keys: Vec, + heap_counts: Vec, + ) -> Result, RmpEncodeError>; + + /// Reads one payload back into `self` and the heap arrays. + fn decode(payload: &[u8]) -> Result<(Self, Vec, Vec), RmpDecodeError> + where + K: for<'de> Deserialize<'de>, + N: for<'de> Deserialize<'de>; +} + +impl Deserialize<'de>> TopKBody for Vec { + fn encode( + self, + keys: Vec, + heap_counts: Vec, + ) -> Result, RmpEncodeError> { + rmp_serde::to_vec(&TopKPayload { + counts: self, + keys, + heap_counts, + }) + } + + fn decode(payload: &[u8]) -> Result<(Self, Vec, Vec), RmpDecodeError> + where + K: for<'de> Deserialize<'de>, + N: for<'de> Deserialize<'de>, + { + let p: TopKPayload = envelope::decode_block(payload)?; + Ok((p.counts, p.keys, p.heap_counts)) + } +} + +/// A delta's changed cells: row-major `indices` and the value added at each. +pub(crate) struct DeltaCells { + pub(crate) indices: Vec, + pub(crate) deltas: Vec, +} + +impl Deserialize<'de>> TopKBody for DeltaCells { + fn encode( + self, + keys: Vec, + heap_counts: Vec, + ) -> Result, RmpEncodeError> { + rmp_serde::to_vec(&TopKDeltaPayload { + indices: self.indices, + deltas: self.deltas, + keys, + heap_counts, + }) + } + + fn decode(payload: &[u8]) -> Result<(Self, Vec, Vec), RmpDecodeError> + where + K: for<'de> Deserialize<'de>, + N: for<'de> Deserialize<'de>, + { + let p: TopKDeltaPayload = envelope::decode_block(payload)?; + let cells = DeltaCells { + indices: p.indices, + deltas: p.deltas, + }; + Ok((cells, p.keys, p.heap_counts)) + } } /// The heap's entries in emitted order: descending count, ties broken by /// [`key_order`]. Independent of the heap array's sift order, so the bytes are /// stable across a round trip. -pub(crate) fn heap_entries(heap: &HHHeap) -> Vec<(&HeapItem, i64)> { - let mut entries: Vec<(&HeapItem, i64)> = heap +pub(crate) fn heap_entries(heap: &HHHeap) -> Vec<(&HeapItem, N)> { + let mut entries: Vec<(&HeapItem, N)> = heap .heap() .iter() .map(|item| (&item.key, item.count)) .collect(); - entries.sort_unstable_by(|a, b| { - b.1.cmp(&a.1) - .then_with(|| key_order(a.0).cmp(&key_order(b.0))) - }); + entries.sort_unstable_by(|a, b| emitted_order(*a, *b)); entries } /// The `key_type` the payload will be written in, taken from the first key in /// emitted order. Rejects a 128-bit key outright; the homogeneity of the rest is /// enforced by [`encode_payload`]. -pub(crate) fn wire_key_type(entries: &[(&HeapItem, i64)]) -> Result<&'static str, RmpEncodeError> { +pub(crate) fn wire_key_type(entries: &[(&HeapItem, N)]) -> Result<&'static str, RmpEncodeError> { match entries.first() { None => Ok(EMPTY_KEY_TYPE), Some((key, _)) => key_type_of(key).ok_or_else(|| { @@ -225,138 +424,87 @@ fn mixed_variant_error(key_type: &str, key: &HeapItem) -> RmpEncodeError { )) } -/// Packs the base counters and the emitted heap entries into the positional +/// Packs the matrix part and the emitted heap entries into the positional /// payload, with `keys` typed by `key_type`. Any key that is not of that variant /// fails the encode, as does a key the entries hold twice on the wire — a live /// heap can seat one, since `NaN` misses its own lookup. -pub(crate) fn encode_payload( +pub(crate) fn encode_payload( key_type: &str, - counts: Vec, - entries: &[(&HeapItem, i64)], + body: B, + entries: &[(&HeapItem, N)], ) -> Result, RmpEncodeError> { check_distinct_keys(entries.iter().map(|(key, _)| *key)) .map_err(|problem| RmpEncodeError::Syntax(problem.to_string()))?; - let heap_counts: Vec = entries.iter().map(|entry| entry.1).collect(); + let heap_counts: Vec = entries.iter().map(|entry| entry.1).collect(); macro_rules! pack { - ($variant:ident) => {{ + ($variant:ident, $value:ident => $wire:expr) => {{ let mut keys = Vec::with_capacity(entries.len()); for (key, _) in entries { match key { - HeapItem::$variant(value) => keys.push(*value), + HeapItem::$variant($value) => keys.push($wire), _ => return Err(mixed_variant_error(key_type, key)), } } - rmp_serde::to_vec(&TopKPayload { - counts, - keys, - heap_counts, - }) + body.encode(keys, heap_counts) }}; } match key_type { - "i8" => pack!(I8), - "i16" => pack!(I16), - "i32" => pack!(I32), - "i64" => pack!(I64), - "isize" => pack!(ISIZE), - "u8" => pack!(U8), - "u16" => pack!(U16), - "u32" => pack!(U32), - "u64" => pack!(U64), - "usize" => pack!(USIZE), - "f32" => pack!(F32), - "f64" => pack!(F64), - "string" => { - let mut keys = Vec::with_capacity(entries.len()); - for (key, _) in entries { - match key { - HeapItem::String(value) => keys.push(value.clone()), - _ => return Err(mixed_variant_error(key_type, key)), - } - } - rmp_serde::to_vec(&TopKPayload { - counts, - keys, - heap_counts, - }) - } - "bytes" => { - let mut keys = Vec::with_capacity(entries.len()); - for (key, _) in entries { - match key { - HeapItem::Bytes(value) => keys.push(WireBytes(value.clone())), - _ => return Err(mixed_variant_error(key_type, key)), - } - } - rmp_serde::to_vec(&TopKPayload { - counts, - keys, - heap_counts, - }) - } + "i8" => pack!(I8, v => *v), + "i16" => pack!(I16, v => *v), + "i32" => pack!(I32, v => *v), + "i64" => pack!(I64, v => *v), + "isize" => pack!(ISIZE, v => *v), + "u8" => pack!(U8, v => *v), + "u16" => pack!(U16, v => *v), + "u32" => pack!(U32, v => *v), + "u64" => pack!(U64, v => *v), + "usize" => pack!(USIZE, v => *v), + "f32" => pack!(F32, v => *v), + "f64" => pack!(F64, v => *v), + "string" => pack!(String, v => v.clone()), + "bytes" => pack!(Bytes, v => WireBytes(v.clone())), other => Err(RmpEncodeError::Syntax(format!( "ASAPv1 top-k heap: key_type {other:?} is not a wire key type" ))), } } -/// The base counters and the heap entries one payload decodes into. -pub(crate) type DecodedTopK = (Vec, Vec<(HeapItem, i64)>); +/// The matrix part and the heap entries one payload decodes into. +pub(crate) type DecodedTopK = (B, Vec<(HeapItem, N)>); /// Reads the payload with `keys` typed by the metadata `key_type`, returning the -/// base counters and the heap entries. Rejects an unknown `key_type` and -/// parallel arrays of unequal length; a `keys` array whose msgpack types do not -/// match `key_type` fails to decode. -pub(crate) fn decode_payload( +/// matrix part and the heap entries. Rejects an unknown `key_type` and parallel +/// arrays of unequal length; a `keys` array whose msgpack types do not match +/// `key_type` fails to decode. +pub(crate) fn decode_payload( key_type: &str, payload: &[u8], -) -> Result, RmpDecodeError> -where - C: for<'de> Deserialize<'de>, -{ +) -> Result, RmpDecodeError> { macro_rules! unpack { - ($variant:ident, $ty:ty) => {{ - let decoded: TopKPayload = envelope::decode_block(payload)?; - ( - decoded.counts, - decoded - .keys - .into_iter() - .map(HeapItem::$variant) - .collect::>(), - decoded.heap_counts, - ) + ($ty:ty, $into:expr) => {{ + let (body, keys, heap_counts) = B::decode::<$ty, N>(payload)?; + let keys: Vec = keys.into_iter().map($into).collect(); + (body, keys, heap_counts) }}; } - let (counts, keys, heap_counts) = match key_type { - "i8" => unpack!(I8, i8), - "i16" => unpack!(I16, i16), - "i32" => unpack!(I32, i32), - "i64" => unpack!(I64, i64), - "isize" => unpack!(ISIZE, isize), - "u8" => unpack!(U8, u8), - "u16" => unpack!(U16, u16), - "u32" => unpack!(U32, u32), - "u64" => unpack!(U64, u64), - "usize" => unpack!(USIZE, usize), - "f32" => unpack!(F32, f32), - "f64" => unpack!(F64, f64), - "string" => unpack!(String, String), - "bytes" => { - let decoded: TopKPayload = envelope::decode_block(payload)?; - ( - decoded.counts, - decoded - .keys - .into_iter() - .map(|key| HeapItem::Bytes(key.into_vec())) - .collect::>(), - decoded.heap_counts, - ) - } + let (body, keys, heap_counts) = match key_type { + "i8" => unpack!(i8, HeapItem::I8), + "i16" => unpack!(i16, HeapItem::I16), + "i32" => unpack!(i32, HeapItem::I32), + "i64" => unpack!(i64, HeapItem::I64), + "isize" => unpack!(isize, HeapItem::ISIZE), + "u8" => unpack!(u8, HeapItem::U8), + "u16" => unpack!(u16, HeapItem::U16), + "u32" => unpack!(u32, HeapItem::U32), + "u64" => unpack!(u64, HeapItem::U64), + "usize" => unpack!(usize, HeapItem::USIZE), + "f32" => unpack!(f32, HeapItem::F32), + "f64" => unpack!(f64, HeapItem::F64), + "string" => unpack!(String, HeapItem::String), + "bytes" => unpack!(WireBytes, |key: WireBytes| HeapItem::Bytes(key.into_vec())), other => { return Err(RmpDecodeError::Uncategorized(format!( "ASAPv1 top-k heap: key_type {other:?} is not a wire key type" @@ -371,32 +519,40 @@ where heap_counts.len() ))); } - Ok((counts, keys.into_iter().zip(heap_counts).collect())) + Ok((body, keys.into_iter().zip(heap_counts).collect())) } -/// Seats the decoded entries into a heap of capacity `k`, rebuilding the index -/// as it goes. `k` is checked against the entry count first and the heap -/// reserves from that count, so a declared capacity never sizes an allocation -/// on its own. +/// Seats the decoded entries into a heap of capacity `heap_size`, rebuilding the +/// index as it goes. `heap_size` is checked against the entry count first and +/// the heap reserves from that count, so a declared capacity never sizes an +/// allocation on its own. /// /// The keys are checked for duplicates on their **wire** representation before /// any is seated — floats by their bits, so two identical `NaN`s are one key -/// twice even though they do not compare equal — and the seated count is -/// checked afterwards, which catches keys that are distinct on the wire but -/// equal to the heap. -pub(crate) fn rebuild_heap( - k: usize, - entries: Vec<(HeapItem, i64)>, -) -> Result { - if entries.len() > k { +/// twice even though they do not compare equal — then for the emitted order, +/// and the seated count is checked afterwards, which catches keys that are +/// distinct on the wire but equal to the heap. +pub(crate) fn rebuild_heap( + heap_size: usize, + entries: Vec<(HeapItem, N)>, +) -> Result, RmpDecodeError> { + if entries.len() > heap_size { return Err(RmpDecodeError::Uncategorized(format!( - "ASAPv1 top-k heap: {} entries over a k of {k}", + "ASAPv1 top-k heap: {} entries over a heap_size of {heap_size}", entries.len() ))); } check_distinct_keys(entries.iter().map(|(key, _)| key)) .map_err(|problem| RmpDecodeError::Uncategorized(problem.to_string()))?; - let mut heap = HHHeap::with_expected_len(k, entries.len()); + if entries + .windows(2) + .any(|pair| emitted_order((&pair[0].0, pair[0].1), (&pair[1].0, pair[1].1)).is_ge()) + { + return Err(RmpDecodeError::Uncategorized( + "ASAPv1 top-k heap: entries are not in descending count, ties by key".to_string(), + )); + } + let mut heap = HHHeap::with_expected_len(heap_size, entries.len()); let seated = entries.len(); for (key, count) in entries { heap.update_heap_item(&key, count); @@ -520,7 +676,7 @@ mod tests { let bytes = sketch.serialize_to_bytes().expect("serialize"); let meta = metadata_of(&bytes); assert_eq!(meta.key_type, EMPTY_KEY_TYPE); - assert_eq!(meta.k, 8); + assert_eq!(meta.heap_size, 8); let decoded = CMSHeap::, RegularPath>::deserialize_from_bytes(&bytes).expect("decode"); @@ -553,7 +709,7 @@ mod tests { ); let (_, _, payload) = envelope::split(&bytes).expect("split"); - let emitted: TopKPayload = from_slice(payload).expect("payload"); + let emitted: TopKPayload = from_slice(payload).expect("payload"); assert_eq!(emitted.heap_counts, vec![9, 9, 5, 5], "not descending"); assert_eq!(emitted.keys, vec![1, 4, 2, 3], "ties not broken by key"); @@ -626,9 +782,9 @@ mod tests { } } - /// A declared `k` is metadata, never an allocation size. + /// A declared `heap_size` is metadata, never an allocation size. #[test] - fn heap_does_not_allocate_a_declared_k() { + fn heap_does_not_allocate_a_declared_heap_size() { let metadata = rmp_serde::to_vec_named(&topk_metadata::( 2, 4, @@ -652,13 +808,13 @@ mod tests { assert_eq!(decoded.heap().len(), 2); } - /// More entries than `k`, and the same key twice, are both states the heap - /// could not have reached. + /// More entries than `heap_size`, and the same key twice, are both states + /// the heap could not have reached. #[test] fn heap_rejects_crafted_entry_sets() { - let crafted = |k: u32, keys: Vec, heap_counts: Vec| { + let crafted = |heap_size: u32, keys: Vec, heap_counts: Vec| { let metadata = rmp_serde::to_vec_named(&topk_metadata::( - 2, 4, "i64", "regular", k, "u64", + 2, 4, "i64", "regular", heap_size, "u64", )) .expect("metadata"); let payload = rmp_serde::to_vec(&TopKPayload { @@ -673,7 +829,7 @@ mod tests { let cases = [ ( crafted(1, vec![1, 2], vec![5, 4]), - "2 entries over a k of 1", + "2 entries over a heap_size of 1", ), ( crafted(4, vec![1, 1], vec![5, 4]), @@ -690,6 +846,36 @@ mod tests { } } + /// Entries must arrive in emitted order: descending count, a tie broken by + /// the key's unsigned bits. + #[test] + fn heap_rejects_entries_out_of_emitted_order() { + let crafted = |keys: Vec, heap_counts: Vec| { + let metadata = rmp_serde::to_vec_named(&topk_metadata::( + 2, 4, "i64", "regular", 4, "i64", + )) + .expect("metadata"); + let payload = rmp_serde::to_vec(&TopKPayload { + counts: vec![0i64; 8], + keys, + heap_counts, + }) + .expect("payload"); + envelope::encode(KIND, &metadata, &payload) + }; + let rows = [ + ("i64 tie", crafted(vec![-1, 1], vec![5, 5])), + ("i64 ascending", crafted(vec![1, 2], vec![4, 9])), + ]; + for (label, bytes) in rows { + let problem = decode_error(&bytes); + assert!( + problem.contains("not in descending count"), + "{label}: {problem}" + ); + } + } + /// Duplicates are judged on the wire bits, not on `HeapItem`'s equality. /// Two bit-identical `NaN`s do not compare equal, so both would seat and the /// seated count would agree; `+0.0` and `-0.0` do compare equal but hash diff --git a/src/sketches/countminsketch_topk/wire.rs b/src/sketches/countminsketch_topk/wire.rs index a658c28f..f583eac4 100644 --- a/src/sketches/countminsketch_topk/wire.rs +++ b/src/sketches/countminsketch_topk/wire.rs @@ -11,21 +11,21 @@ //! CMSHeap is one algorithm — a single kind_id `0x03 0x00`. The structural //! parameters — the matrix dimensions (`rows` / `cols`), the base **counter //! type** (i32/i64/f64), the column-derivation **mode** (fast/regular), the heap -//! capacity `k` and the heap's `key_type` — all live in the metadata, so the -//! payload is `[counts, keys, heap_counts]`: the base matrix packed row-major -//! followed by the heap's entries. +//! capacity `heap_size` and the heap's `key_type` — all live in the metadata, so +//! the payload is `[counts, keys, heap_counts]`: the base matrix packed row-major +//! followed by the heap's entries, whose counts are `f64` under `f64` counters +//! and `i64` otherwise. use rmp_serde::{decode::Error as RmpDecodeError, encode::Error as RmpEncodeError}; use serde::{Deserialize, Serialize}; -use crate::common::hash::check_matrix_rows; use crate::message_pack_format::envelope; use crate::sketches::countminsketch::{CmsWireCounter, CmsWireMode}; -use crate::{CountMin, HashProfile, SketchHasher, Vector2D}; +use crate::{CountMin, HashProfile, SketchHasher, TopKCounter, Vector2D}; use super::CMSHeap; use super::heap_wire::{ - TopKMetadata, decode_payload, encode_payload, heap_entries, rebuild_heap, topk_metadata, + decode_metadata, decode_payload, encode_metadata, encode_payload, heap_entries, rebuild_heap, wire_key_type, }; @@ -39,7 +39,7 @@ impl CMSHeap, Mode, H> where // `AddAssign` is required for `Vector2D: MatrixStorage` (the struct's // bound), not by the bodies below. - T: CmsWireCounter + std::ops::AddAssign + Serialize + for<'de> Deserialize<'de>, + T: CmsWireCounter + TopKCounter + std::ops::AddAssign + Serialize + for<'de> Deserialize<'de>, Mode: CmsWireMode, H: SketchHasher + HashProfile, { @@ -47,15 +47,25 @@ where /// (kind_id `0x03 0x00`). The metadata is derived from the hasher's /// [`HashProfile`], so it truthfully describes how the sketch was hashed. /// - /// Fails when the matrix's cell count disagrees with its own dimensions, - /// when the heap's keys mix `HeapItem` variants or hold a 128-bit key, or - /// when `k` or `cols` overflows the metadata's `u32` field. + /// Fails when the matrix has a zero dimension, more than `MATRIX_MAX_ROWS` + /// rows or a cell count that disagrees with its own dimensions, when the + /// heap's keys mix `HeapItem` variants or hold a 128-bit key, or when + /// `heap_size` or `cols` overflows the metadata's `u32` field. pub fn serialize_to_bytes(&self) -> Result, RmpEncodeError> { let rows = self.cms.rows(); let cols = self.cms.cols(); let counts = self.cms.as_storage().as_slice(); - check_matrix_rows("CMSHeap", rows) - .map_err(|e| RmpEncodeError::Syntax(format!("ASAPv1 CMSHeap envelope: {e}")))?; + let entries = heap_entries(&self.heap); + let key_type = wire_key_type(&entries)?; + let metadata = encode_metadata::( + "CMSHeap", + rows, + cols, + T::COUNTER_TYPE, + Mode::MODE, + self.heap.capacity(), + key_type, + )?; if counts.len() != rows.saturating_mul(cols) { return Err(RmpEncodeError::Syntax(format!( "ASAPv1 CMSHeap envelope: counts length {} != rows*cols {}", @@ -63,40 +73,19 @@ where rows.saturating_mul(cols) ))); } - let k = u32::try_from(self.heap.capacity()).map_err(|_| { - RmpEncodeError::Syntax(format!( - "ASAPv1 CMSHeap envelope: heap k {} exceeds the u32 metadata field", - self.heap.capacity() - )) - })?; - let wire_cols = u32::try_from(cols).map_err(|_| { - RmpEncodeError::Syntax(format!( - "ASAPv1 CMSHeap envelope: cols {cols} exceeds the u32 metadata field" - )) - })?; - let entries = heap_entries(&self.heap); - let key_type = wire_key_type(&entries)?; - let metadata = rmp_serde::to_vec_named(&topk_metadata::( - rows as u32, - wire_cols, - T::COUNTER_TYPE, - Mode::MODE, - k, - key_type, - ))?; let payload = encode_payload(key_type, counts.to_vec(), &entries)?; Ok(envelope::encode(CMS_HEAP_KIND, &metadata, &payload)) } /// Deserializes a sketch from an ASAPv1 MessagePack envelope. The matrix - /// dimensions, `k` and `key_type` are structural (they are properties of - /// the stored sketch, not of the target), so they are echoed back into the - /// expected metadata; the hash spec, counter type and mode are pinned - /// against this target. + /// dimensions, `heap_size` and `key_type` are structural (they are + /// properties of the stored sketch, not of the target), so they are echoed + /// back into the expected metadata; the hash spec, counter type and mode are + /// pinned against this target. /// /// Every state the algorithm could not have produced is rejected with an - /// error rather than a panic, and neither the declared geometry nor `k` - /// sizes an allocation before the payload is measured against it. + /// error rather than a panic, and neither the declared geometry nor + /// `heap_size` sizes an allocation before the payload is measured against it. pub fn deserialize_from_bytes(bytes: &[u8]) -> Result { let (kind_id, metadata, payload) = envelope::split(bytes).map_err(RmpDecodeError::Uncategorized)?; @@ -105,31 +94,9 @@ where "CMSHeap kind_id mismatch: stored {kind_id:?}, expected {CMS_HEAP_KIND:?}" ))); } - let meta: TopKMetadata = envelope::decode_block(metadata)?; - if meta - != topk_metadata::( - meta.rows, - meta.cols, - T::COUNTER_TYPE, - Mode::MODE, - meta.k, - &meta.key_type, - ) - { - return Err(RmpDecodeError::Uncategorized( - "ASAPv1 CMSHeap envelope: metadata mismatch".to_string(), - )); - } - let (rows, cols) = (meta.rows as usize, meta.cols as usize); - // Reject zero dimensions before building the matrix: `Vector2D::from_fn` - // derives its mask via `cols.ilog2()`, which panics on `cols == 0`. - if rows == 0 || cols == 0 { - return Err(RmpDecodeError::Uncategorized(format!( - "CMSHeap dimensions must be non-zero: rows={rows}, cols={cols}" - ))); - } - check_matrix_rows("CMSHeap", rows).map_err(RmpDecodeError::Uncategorized)?; - let (counts, entries) = decode_payload::(&meta.key_type, payload)?; + let shape = decode_metadata::("CMSHeap", metadata, T::COUNTER_TYPE, Mode::MODE)?; + let (rows, cols) = (shape.rows, shape.cols); + let (counts, entries) = decode_payload::, _>(&shape.key_type, payload)?; // Length check precedes the allocation, so crafted dimensions cannot // drive `from_fn` into a huge reserve. if counts.len() != rows.saturating_mul(cols) { @@ -139,7 +106,7 @@ where rows.saturating_mul(cols) ))); } - let heap = rebuild_heap(meta.k as usize, entries)?; + let heap = rebuild_heap(shape.heap_size, entries)?; let storage = Vector2D::from_fn(rows, cols, |r, c| counts[r * cols + c]); Ok(CMSHeap { cms: CountMin::from_storage(storage), @@ -151,7 +118,9 @@ where #[cfg(test)] mod tests { use super::*; - use crate::sketches::countminsketch_topk::heap_wire::TopKPayload; + use crate::sketches::countminsketch_topk::heap_wire::{ + TopKMetadata, TopKPayload, topk_metadata, + }; use crate::{ CANONICAL_HASH_SEED, DataInput, DefaultXxHasher, FastPath, MATRIX_MAX_ROWS, RegularPath, }; @@ -191,7 +160,7 @@ mod tests { assert_eq!((meta.rows, meta.cols), (3, 8)); assert_eq!(meta.counter_type, "i64"); assert_eq!(meta.mode, "regular"); - assert_eq!(meta.k, 4); + assert_eq!(meta.heap_size, 4); assert_eq!(meta.key_type, "u64"); let decoded = CMSHeap::, RegularPath>::deserialize_from_bytes(&encoded) @@ -224,7 +193,7 @@ mod tests { Vector2D::from_fn(2, 4, |r, c| (r * 4 + c) as f64 + 0.5), 4, ); - sketch.heap_mut().update(&DataInput::Str("flow"), 11); + sketch.heap_mut().update(&DataInput::Str("flow"), 11.0); let encoded = sketch.serialize_to_bytes().expect("serialize"); assert_eq!(metadata_of(&encoded).counter_type, "f64"); @@ -332,12 +301,12 @@ mod tests { /// Builds a well-framed envelope around crafted metadata and a `u64`-keyed /// payload. - fn crafted(rows: u32, cols: u32, k: u32, counts: Vec) -> Vec { + fn crafted(rows: u32, cols: u32, heap_size: u32, counts: Vec) -> Vec { let metadata = rmp_serde::to_vec_named(&topk_metadata::( - rows, cols, "i64", "regular", k, "u64", + rows, cols, "i64", "regular", heap_size, "u64", )) .expect("metadata"); - let payload = rmp_serde::to_vec(&TopKPayload:: { + let payload = rmp_serde::to_vec(&TopKPayload:: { counts, keys: Vec::new(), heap_counts: Vec::new(), @@ -388,6 +357,18 @@ mod tests { ); } + /// A `rows == 0` matrix satisfies the length check trivially but is refused + /// on decode, so serializing it must fail. + #[test] + fn cms_heap_rejects_serializing_zero_rows() { + let sketch = CMSHeap::, RegularPath>::new(0, 4, 4); + let problem = sketch + .serialize_to_bytes() + .expect_err("a zero-row matrix must not serialize") + .to_string(); + assert!(problem.contains("non-zero"), "got {problem}"); + } + /// An unpopulated matrix carries dimensions its cells do not match. /// Serializing it must fail rather than emit bytes the decoder refuses. #[test] @@ -416,7 +397,7 @@ mod tests { cols: u32, counter_type: String, mode: String, - k: u32, + heap_size: u32, key_type: String, bogus_field: u8, // key not in TopKMetadata } @@ -433,7 +414,7 @@ mod tests { cols: m.cols, counter_type: m.counter_type.clone(), mode: m.mode.clone(), - k: m.k, + heap_size: m.heap_size, key_type: m.key_type.clone(), bogus_field: 7, }; @@ -444,12 +425,12 @@ mod tests { ); } - /// `k` is required: a metadata map missing it does not decode, so the heap - /// capacity can never be silently defaulted. + /// `heap_size` is required: a metadata map missing it does not decode, so + /// the heap capacity can never be silently defaulted. #[test] - fn cms_heap_metadata_rejects_a_missing_k_key() { + fn cms_heap_metadata_rejects_a_missing_heap_size_key() { #[derive(Serialize)] - struct WithoutK { + struct WithoutHeapSize { metadata_version: u8, hash_profile_id: String, hash_algorithm: String, @@ -464,7 +445,7 @@ mod tests { key_type: String, } let m = topk_metadata::(2, 4, "i64", "regular", 4, "u64"); - let without = WithoutK { + let without = WithoutHeapSize { metadata_version: m.metadata_version, hash_profile_id: m.hash_profile_id.clone(), hash_algorithm: m.hash_algorithm.clone(), @@ -481,7 +462,7 @@ mod tests { let bytes = rmp_serde::to_vec_named(&without).expect("encode"); assert!( from_slice::(&bytes).is_err(), - "a missing k must be rejected" + "a missing heap_size must be rejected" ); } @@ -495,7 +476,7 @@ mod tests { 2, 4, "i32", "regular", 4, "u64", )) .expect("metadata"); - let payload = rmp_serde::to_vec(&TopKPayload:: { + let payload = rmp_serde::to_vec(&TopKPayload:: { counts: vec![0; 8], keys: Vec::new(), heap_counts: Vec::new(), @@ -585,17 +566,17 @@ mod tests { ); } - /// A `k` past the metadata's `u32` field fails the encode rather than + /// A `heap_size` past the metadata's `u32` field fails the encode rather than /// emitting a truncated capacity. #[test] - fn cms_heap_refuses_a_k_the_metadata_cannot_carry() { + fn cms_heap_refuses_a_heap_size_the_metadata_cannot_carry() { let sketch = CMSHeap::, RegularPath>::from_storage( Vector2D::from_fn(2, 4, |_, _| 0i64), 1usize << 40, ); let problem = sketch .serialize_to_bytes() - .expect_err("an oversized k must not serialize") + .expect_err("an oversized heap_size must not serialize") .to_string(); assert!( problem.contains("exceeds the u32 metadata field"), diff --git a/src/sketches/countsketch_topk/wire.rs b/src/sketches/countsketch_topk/wire.rs index abe35222..3efc4e41 100644 --- a/src/sketches/countsketch_topk/wire.rs +++ b/src/sketches/countsketch_topk/wire.rs @@ -11,8 +11,8 @@ //! CSHeap is one algorithm — a single kind_id `0x0a 0x00`. The structural //! parameters — the matrix dimensions (`rows` / `cols`), the base **counter //! type** (i32/i64/f64), the column-derivation **mode** (fast/regular), the heap -//! capacity `k` and the heap's `key_type` — all live in the metadata, so the -//! payload is `[counts, keys, heap_counts]`: the base matrix packed row-major +//! capacity `heap_size` and the heap's `key_type` — all live in the metadata, so +//! the payload is `[counts, keys, heap_counts]`: the base matrix packed row-major //! followed by the heap's entries. //! //! [`CountL2HH`](super::CountL2HH) is a different algorithm and is not covered @@ -21,10 +21,9 @@ use rmp_serde::{decode::Error as RmpDecodeError, encode::Error as RmpEncodeError}; use serde::{Deserialize, Serialize}; -use crate::common::hash::check_matrix_rows; use crate::message_pack_format::envelope; use crate::sketches::countminsketch_topk::heap_wire::{ - TopKMetadata, decode_payload, encode_payload, heap_entries, rebuild_heap, topk_metadata, + decode_metadata, decode_payload, encode_metadata, encode_payload, heap_entries, rebuild_heap, wire_key_type, }; use crate::sketches::countsketch::{CountSketchCounter, CsWireCounter, CsWireMode}; @@ -54,15 +53,25 @@ where /// (kind_id `0x0a 0x00`). The metadata is derived from the hasher's /// [`HashProfile`], so it truthfully describes how the sketch was hashed. /// - /// Fails when the matrix's cell count disagrees with its own dimensions, - /// when the heap's keys mix `HeapItem` variants or hold a 128-bit key, or - /// when `k` or `cols` overflows the metadata's `u32` field. + /// Fails when the matrix has a zero dimension, more than `MATRIX_MAX_ROWS` + /// rows or a cell count that disagrees with its own dimensions, when the + /// heap's keys mix `HeapItem` variants or hold a 128-bit key, or when + /// `heap_size` or `cols` overflows the metadata's `u32` field. pub fn serialize_to_bytes(&self) -> Result, RmpEncodeError> { let rows = self.cs.rows(); let cols = self.cs.cols(); let counts = self.cs.as_storage().as_slice(); - check_matrix_rows("CSHeap", rows) - .map_err(|e| RmpEncodeError::Syntax(format!("ASAPv1 CSHeap envelope: {e}")))?; + let entries = heap_entries(&self.heap); + let key_type = wire_key_type(&entries)?; + let metadata = encode_metadata::( + "CSHeap", + rows, + cols, + T::COUNTER_TYPE, + Mode::MODE, + self.heap.capacity(), + key_type, + )?; if counts.len() != rows.saturating_mul(cols) { return Err(RmpEncodeError::Syntax(format!( "ASAPv1 CSHeap envelope: counts length {} != rows*cols {}", @@ -70,40 +79,19 @@ where rows.saturating_mul(cols) ))); } - let k = u32::try_from(self.heap.capacity()).map_err(|_| { - RmpEncodeError::Syntax(format!( - "ASAPv1 CSHeap envelope: heap k {} exceeds the u32 metadata field", - self.heap.capacity() - )) - })?; - let wire_cols = u32::try_from(cols).map_err(|_| { - RmpEncodeError::Syntax(format!( - "ASAPv1 CSHeap envelope: cols {cols} exceeds the u32 metadata field" - )) - })?; - let entries = heap_entries(&self.heap); - let key_type = wire_key_type(&entries)?; - let metadata = rmp_serde::to_vec_named(&topk_metadata::( - rows as u32, - wire_cols, - T::COUNTER_TYPE, - Mode::MODE, - k, - key_type, - ))?; let payload = encode_payload(key_type, counts.to_vec(), &entries)?; Ok(envelope::encode(CS_HEAP_KIND, &metadata, &payload)) } /// Deserializes a sketch from an ASAPv1 MessagePack envelope. The matrix - /// dimensions, `k` and `key_type` are structural (they are properties of - /// the stored sketch, not of the target), so they are echoed back into the - /// expected metadata; the hash spec, counter type and mode are pinned - /// against this target. + /// dimensions, `heap_size` and `key_type` are structural (they are + /// properties of the stored sketch, not of the target), so they are echoed + /// back into the expected metadata; the hash spec, counter type and mode are + /// pinned against this target. /// /// Every state the algorithm could not have produced is rejected with an - /// error rather than a panic, and neither the declared geometry nor `k` - /// sizes an allocation before the payload is measured against it. + /// error rather than a panic, and neither the declared geometry nor + /// `heap_size` sizes an allocation before the payload is measured against it. pub fn deserialize_from_bytes(bytes: &[u8]) -> Result { let (kind_id, metadata, payload) = envelope::split(bytes).map_err(RmpDecodeError::Uncategorized)?; @@ -112,31 +100,9 @@ where "CSHeap kind_id mismatch: stored {kind_id:?}, expected {CS_HEAP_KIND:?}" ))); } - let meta: TopKMetadata = envelope::decode_block(metadata)?; - if meta - != topk_metadata::( - meta.rows, - meta.cols, - T::COUNTER_TYPE, - Mode::MODE, - meta.k, - &meta.key_type, - ) - { - return Err(RmpDecodeError::Uncategorized( - "ASAPv1 CSHeap envelope: metadata mismatch".to_string(), - )); - } - let (rows, cols) = (meta.rows as usize, meta.cols as usize); - // Reject zero dimensions before building the matrix: `Vector2D::from_fn` - // derives its mask via `cols.ilog2()`, which panics on `cols == 0`. - if rows == 0 || cols == 0 { - return Err(RmpDecodeError::Uncategorized(format!( - "CSHeap dimensions must be non-zero: rows={rows}, cols={cols}" - ))); - } - check_matrix_rows("CSHeap", rows).map_err(RmpDecodeError::Uncategorized)?; - let (counts, entries) = decode_payload::(&meta.key_type, payload)?; + let shape = decode_metadata::("CSHeap", metadata, T::COUNTER_TYPE, Mode::MODE)?; + let (rows, cols) = (shape.rows, shape.cols); + let (counts, entries) = decode_payload::, _>(&shape.key_type, payload)?; // Length check precedes the allocation, so crafted dimensions cannot // drive `from_fn` into a huge reserve. if counts.len() != rows.saturating_mul(cols) { @@ -146,7 +112,7 @@ where rows.saturating_mul(cols) ))); } - let heap = rebuild_heap(meta.k as usize, entries)?; + let heap = rebuild_heap(shape.heap_size, entries)?; let storage = Vector2D::from_fn(rows, cols, |r, c| counts[r * cols + c]); Ok(CSHeap { cs: Count::from_storage(storage), @@ -158,7 +124,9 @@ where #[cfg(test)] mod tests { use super::*; - use crate::sketches::countminsketch_topk::heap_wire::TopKPayload; + use crate::sketches::countminsketch_topk::heap_wire::{ + TopKMetadata, TopKPayload, topk_metadata, + }; use crate::{ CANONICAL_HASH_SEED, DataInput, DefaultXxHasher, FastPath, MATRIX_MAX_ROWS, RegularPath, }; @@ -198,7 +166,7 @@ mod tests { assert_eq!((meta.rows, meta.cols), (3, 8)); assert_eq!(meta.counter_type, "i64"); assert_eq!(meta.mode, "regular"); - assert_eq!(meta.k, 4); + assert_eq!(meta.heap_size, 4); assert_eq!(meta.key_type, "u64"); let decoded = CSHeap::, RegularPath>::deserialize_from_bytes(&encoded) @@ -329,12 +297,12 @@ mod tests { /// Builds a well-framed envelope around crafted metadata and a `u64`-keyed /// payload. - fn crafted(rows: u32, cols: u32, k: u32, counts: Vec) -> Vec { + fn crafted(rows: u32, cols: u32, heap_size: u32, counts: Vec) -> Vec { let metadata = rmp_serde::to_vec_named(&topk_metadata::( - rows, cols, "i64", "regular", k, "u64", + rows, cols, "i64", "regular", heap_size, "u64", )) .expect("metadata"); - let payload = rmp_serde::to_vec(&TopKPayload:: { + let payload = rmp_serde::to_vec(&TopKPayload:: { counts, keys: Vec::new(), heap_counts: Vec::new(), @@ -413,7 +381,7 @@ mod tests { cols: u32, counter_type: String, mode: String, - k: u32, + heap_size: u32, key_type: String, bogus_field: u8, // key not in TopKMetadata } @@ -430,7 +398,7 @@ mod tests { cols: m.cols, counter_type: m.counter_type.clone(), mode: m.mode.clone(), - k: m.k, + heap_size: m.heap_size, key_type: m.key_type.clone(), bogus_field: 7, }; @@ -458,7 +426,7 @@ mod tests { cols: u32, counter_type: String, mode: String, - k: u32, + heap_size: u32, } let m = topk_metadata::(2, 4, "i64", "regular", 4, "u64"); let without = WithoutKeyType { @@ -473,7 +441,7 @@ mod tests { cols: m.cols, counter_type: m.counter_type.clone(), mode: m.mode.clone(), - k: m.k, + heap_size: m.heap_size, }; let bytes = rmp_serde::to_vec_named(&without).expect("encode"); assert!( @@ -489,7 +457,7 @@ mod tests { 2, 4, "f64", "regular", 4, "u64", )) .expect("metadata"); - let payload = rmp_serde::to_vec(&TopKPayload:: { + let payload = rmp_serde::to_vec(&TopKPayload:: { counts: vec![0; 8], keys: Vec::new(), heap_counts: Vec::new(), @@ -583,17 +551,17 @@ mod tests { ); } - /// A `k` past the metadata's `u32` field fails the encode rather than + /// A `heap_size` past the metadata's `u32` field fails the encode rather than /// emitting a truncated capacity. #[test] - fn cs_heap_refuses_a_k_the_metadata_cannot_carry() { + fn cs_heap_refuses_a_heap_size_the_metadata_cannot_carry() { let sketch = CSHeap::, RegularPath>::from_storage( Vector2D::from_fn(2, 4, |_, _| 0i64), 1usize << 40, ); let problem = sketch .serialize_to_bytes() - .expect_err("an oversized k must not serialize") + .expect_err("an oversized heap_size must not serialize") .to_string(); assert!( problem.contains("exceeds the u32 metadata field"), diff --git a/src/sketches/mod.rs b/src/sketches/mod.rs index 80ca1433..e1372430 100644 --- a/src/sketches/mod.rs +++ b/src/sketches/mod.rs @@ -107,7 +107,7 @@ pub mod ddsketch; pub use ddsketch::DDSketch; pub mod countminsketch_topk; -pub use countminsketch_topk::CMSHeap; +pub use countminsketch_topk::{CMSHeap, CMSHeapDelta}; pub mod countsketch_topk; pub use countsketch_topk::{CSHeap, cs_heap_count}; diff --git a/tests/asapv1_golden.rs b/tests/asapv1_golden.rs index cd827899..da6e20ce 100644 --- a/tests/asapv1_golden.rs +++ b/tests/asapv1_golden.rs @@ -10,8 +10,9 @@ use asap_sketchlib::sketches::CountSketchDelta; use asap_sketchlib::{ - Classic, Count, CountMin, CountMinDelta, ErtlMLE, FastPath, HllSketch, HllVariant, - HyperLogLogHIPP12, HyperLogLogP12, KLL, MessagePackCodec, RegularPath, Vector2D, peek_kind_id, + CMSHeap, CMSHeapDelta, CSHeap, Classic, Count, CountMin, CountMinDelta, ErtlMLE, FastPath, + HHHeap, HeapCount, HeapItem, HllSketch, HllVariant, HyperLogLogHIPP12, HyperLogLogP12, KLL, + MessagePackCodec, RegularPath, Vector2D, peek_kind_id, }; fn decode_hex(s: &str) -> Vec { @@ -36,6 +37,19 @@ const GOLDEN_CS_I32: &str = include_str!("../asapv1_golden/cs_i32_regular_2x4.he const GOLDEN_CS_F64_FAST: &str = include_str!("../asapv1_golden/cs_f64_fast_2x4.hex"); const GOLDEN_CS_I64_DELTA: &str = include_str!("../asapv1_golden/cs_i64_regular_2x4_delta.hex"); const GOLDEN_CS_F64_DELTA: &str = include_str!("../asapv1_golden/cs_f64_fast_2x4_delta.hex"); +const GOLDEN_CMSHEAP_STR: &str = + include_str!("../asapv1_golden/cmsheap_i64_regular_2x3_strkeys.hex"); +const GOLDEN_CMSHEAP_I32: &str = include_str!("../asapv1_golden/cmsheap_i32_fast_2x3_i64keys.hex"); +const GOLDEN_CMSHEAP_I64_TIE: &str = + include_str!("../asapv1_golden/cmsheap_i64_regular_2x3_i64tie.hex"); +const GOLDEN_CMSHEAP_STR_TIE: &str = + include_str!("../asapv1_golden/cmsheap_i64_regular_2x3_strtie.hex"); +const GOLDEN_CMSHEAP_EMPTY: &str = + include_str!("../asapv1_golden/cmsheap_i64_regular_2x3_empty.hex"); +const GOLDEN_CMSHEAP_F64: &str = include_str!("../asapv1_golden/cmsheap_f64_fast_2x3_f64keys.hex"); +const GOLDEN_CMSHEAP_DELTA: &str = + include_str!("../asapv1_golden/cmsheap_i64_regular_2x3_delta.hex"); +const GOLDEN_CSHEAP_STR: &str = include_str!("../asapv1_golden/csheap_i64_regular_2x4_strkeys.hex"); const GOLDEN_KLL_F64: &str = include_str!("../asapv1_golden/kll_f64_k200.hex"); const GOLDEN_KLL_I64: &str = include_str!("../asapv1_golden/kll_i64_k200.hex"); @@ -348,6 +362,181 @@ fn cs_f64_fast_2x4_delta_matches_golden() { assert_eq!(state.as_storage().as_slice(), expected.as_slice()); } +// --------------------------------------------------------------------------- +// CMSHeap / CSHeap: the matrix and the heap entries set directly -> serialize == +// golden, round-trips. Entries are seated out of emitted order. +// --------------------------------------------------------------------------- + +/// `(key, count)` pairs as string-keyed heap entries. +fn str_entries(entries: &[(&str, i64)]) -> Vec<(HeapItem, i64)> { + entries + .iter() + .map(|&(key, count)| (HeapItem::String(key.to_string()), count)) + .collect() +} + +/// `(key, count)` pairs as `i64`-keyed heap entries. +fn i64_entries(entries: &[(i64, i64)]) -> Vec<(HeapItem, i64)> { + entries + .iter() + .map(|&(key, count)| (HeapItem::I64(key), count)) + .collect() +} + +/// A heap of capacity `heap_size` holding `entries`, seated in the given order. +fn heap_of(heap_size: usize, entries: &[(HeapItem, N)]) -> HHHeap { + let mut heap = HHHeap::new(heap_size); + for (key, count) in entries { + assert!(heap.update_heap_item(key, *count)); + } + heap +} + +/// Asserts `heap` has capacity `heap_size` and holds exactly `entries`. +fn assert_heap(heap: &HHHeap, heap_size: usize, entries: &[(HeapItem, N)]) { + assert_eq!(heap.capacity(), heap_size); + assert_eq!(heap.len(), entries.len()); + for (key, count) in entries { + let at = heap.find_heap_item(key).expect("heap key"); + assert!(heap.heap()[at].count.order(count).is_eq(), "{key:?}"); + } +} + +type CmsHeapI64 = CMSHeap, RegularPath>; + +/// The i64 regular-path CMSHeap over the Count-Min i64 matrix with `entries`. +fn cmsheap_i64(heap_size: usize, entries: &[(HeapItem, i64)]) -> CmsHeapI64 { + let mut sketch = + CmsHeapI64::from_storage(Vector2D::from_fn(2, 3, |r, c| I64_VALS[r][c]), heap_size); + *sketch.heap_mut() = heap_of(heap_size, entries); + sketch +} + +fn check_cmsheap_i64_golden(golden: &str, heap_size: usize, entries: &[(HeapItem, i64)]) { + let want = decode_hex(golden); + let got = cmsheap_i64(heap_size, entries).serialize_to_bytes(); + assert_eq!(got.expect("serialize"), want); + + let decoded = CmsHeapI64::deserialize_from_bytes(&want).expect("decode"); + let flat: Vec = I64_VALS.iter().flatten().copied().collect(); + assert_eq!(decoded.cms().as_storage().as_slice(), flat.as_slice()); + assert_heap(decoded.heap(), heap_size, entries); + assert_eq!(decoded.serialize_to_bytes().expect("re-serialize"), want); +} + +#[test] +fn cmsheap_i64_regular_2x3_strkeys_matches_golden() { + let entries = [("cold", 1), ("warm", 300), ("hot", 65536), ("mild", 300)]; + check_cmsheap_i64_golden(GOLDEN_CMSHEAP_STR, 5, &str_entries(&entries)); +} + +/// Signed keys tie by their two's-complement bits read unsigned. +#[test] +fn cmsheap_i64_regular_2x3_i64tie_matches_golden() { + let entries = [(-1, 5), (1, 5), (0, 5), (i64::MIN, 5), (2, 9)]; + check_cmsheap_i64_golden(GOLDEN_CMSHEAP_I64_TIE, 5, &i64_entries(&entries)); +} + +/// String keys tie byte-wise, a proper prefix first. +#[test] +fn cmsheap_i64_regular_2x3_strtie_matches_golden() { + let entries = [("b", 5), ("aa", 5), ("Z", 5), ("a", 5), ("hot", 9)]; + check_cmsheap_i64_golden(GOLDEN_CMSHEAP_STR_TIE, 5, &str_entries(&entries)); +} + +#[test] +fn cmsheap_i64_regular_2x3_empty_matches_golden() { + check_cmsheap_i64_golden(GOLDEN_CMSHEAP_EMPTY, 4, &[]); +} + +#[test] +fn cmsheap_i32_fast_2x3_i64keys_matches_golden() { + type Heap = CMSHeap, FastPath>; + let want = decode_hex(GOLDEN_CMSHEAP_I32); + let entries = i64_entries(&[(4_294_967_296, 3), (-1, 7), (-129, 7)]); + let mut sketch = Heap::from_storage(Vector2D::from_fn(2, 3, |r, c| I64_VALS[r][c] as i32), 3); + *sketch.heap_mut() = heap_of(3, &entries); + assert_eq!(sketch.serialize_to_bytes().expect("serialize"), want); + + let decoded = Heap::deserialize_from_bytes(&want).expect("decode"); + let flat: Vec = I64_VALS.iter().flatten().map(|&v| v as i32).collect(); + assert_eq!(decoded.cms().as_storage().as_slice(), flat.as_slice()); + assert_heap(decoded.heap(), 3, &entries); + assert_eq!(decoded.serialize_to_bytes().expect("re-serialize"), want); +} + +/// `f64` heap counts order by `total_cmp`: `+0.0` before `-0.0`, the +/// infinities at the ends; the `300.25` tie falls to the key's bits. +#[test] +fn cmsheap_f64_fast_2x3_f64keys_matches_golden() { + type Heap = CMSHeap, FastPath>; + let want = decode_hex(GOLDEN_CMSHEAP_F64); + let entries: Vec<(HeapItem, f64)> = [ + (8.0, 0.0), + (-0.5, 300.25), + (9.0, f64::NEG_INFINITY), + (7.0, -0.0), + (1.5, 300.25), + (2.0, f64::INFINITY), + ] + .into_iter() + .map(|(key, count)| (HeapItem::F64(key), count)) + .collect(); + let mut sketch = Heap::from_storage(Vector2D::from_fn(2, 3, |r, c| F64_VALS[r][c]), 6); + *sketch.heap_mut() = heap_of(6, &entries); + assert_eq!(sketch.serialize_to_bytes().expect("serialize"), want); + + let decoded = Heap::deserialize_from_bytes(&want).expect("decode"); + let flat: Vec = F64_VALS.iter().flatten().copied().collect(); + assert_eq!(decoded.cms().as_storage().as_slice(), flat.as_slice()); + assert_heap(decoded.heap(), 6, &entries); + assert_eq!(decoded.serialize_to_bytes().expect("re-serialize"), want); +} + +/// The delta carries `i64` keys into a `string`-keyed state: applying it adds +/// the cells and replaces the heap, `key_type` with it. +#[test] +fn cmsheap_i64_regular_2x3_delta_matches_golden() { + type Delta = CMSHeapDelta; + let want = decode_hex(GOLDEN_CMSHEAP_DELTA); + let entries = i64_entries(&[(7, 128), (-129, 128), (-1, 4_295_032_832)]); + let indices = vec![0, 1, 2, 4, 5]; + let deltas = vec![127, -1, -33, -32769, 4294967296]; + let delta = Delta::new(2, 3, indices, deltas, heap_of(5, &entries)).expect("valid"); + assert_eq!(delta.serialize_to_bytes().expect("serialize"), want); + + let decoded = Delta::deserialize_from_bytes(&want).expect("decode"); + assert_eq!(decoded.serialize_to_bytes().expect("re-serialize"), want); + + let mut state = + CmsHeapI64::deserialize_from_bytes(&decode_hex(GOLDEN_CMSHEAP_STR)).expect("state"); + state.merge_delta(&decoded).expect("merge"); + let expected = [127, 0, 94, 128, -32469, 4295032832]; + assert_eq!(state.cms().as_storage().as_slice(), expected.as_slice()); + assert_heap(state.heap(), 5, &entries); +} + +#[test] +fn csheap_i64_regular_2x4_strkeys_matches_golden() { + type Heap = CSHeap, RegularPath>; + let want = decode_hex(GOLDEN_CSHEAP_STR); + let entries = str_entries(&[ + ("gamma", -33), + ("delta", 127), + ("alpha", 4_294_967_296), + ("beta", 127), + ]); + let mut sketch = Heap::from_storage(Vector2D::from_fn(2, 4, |r, c| CS_VALS[r][c]), 5); + *sketch.heap_mut() = heap_of(5, &entries); + assert_eq!(sketch.serialize_to_bytes().expect("serialize"), want); + + let decoded = Heap::deserialize_from_bytes(&want).expect("decode"); + let flat: Vec = CS_VALS.iter().flatten().copied().collect(); + assert_eq!(decoded.cs().as_storage().as_slice(), flat.as_slice()); + assert_heap(decoded.heap(), 5, &entries); + assert_eq!(decoded.serialize_to_bytes().expect("re-serialize"), want); +} + // --------------------------------------------------------------------------- // KLL: build known state (k=200, seed 42, integers 1..=50 — below the level-0 // capacity, so no compaction fires and the retained set is deterministic) -> diff --git a/tests/e2e/matrix_instances.rs b/tests/e2e/matrix_instances.rs index ec2d35e8..28e46c9a 100644 --- a/tests/e2e/matrix_instances.rs +++ b/tests/e2e/matrix_instances.rs @@ -574,11 +574,11 @@ macro_rules! heap_matrix_test { // The two heap families do **not** have the same instance coverage, and the // reason is a difference in their insert bounds rather than an oversight: // -// - `CMSHeap`'s insert path is bounded on `S::Counter: Copy + Ord + From + -// Into + AddAssign`. `i128` has no `Into` and `f64` has neither -// `Ord` nor `Into`, so those instances construct but have no `insert`, -// `estimate` or `merge` at all. They are enumerated and pinned as inert by -// `cmsheap_instances_without_an_insert_impl_are_inert_by_construction`. +// - `CMSHeap`'s insert path needs the counter to convert into its heap count +// (`i64`, or `f64` for an `f64` counter). `i128` has no `Into`, so those +// instances construct but have no `insert`, `estimate` or `merge` at all. +// They are enumerated and pinned as inert by +// `cmsheap_inert_instances_construct_and_report_their_geometry`. // - `CSHeap`'s insert path is bounded on `S::Counter: CountSketchCounter`, // which `i128` *does* satisfy. All six `i128` instances are fully // operational and are covered below; the previous revision of this file and @@ -760,21 +760,18 @@ fn heap_count(items: &[asap_sketchlib::HHItem], key: &DataInput) -> Option /// The `CMSHeap` instances that construct but have no operations at all. /// /// `CMSHeap::insert` / `insert_many` / `estimate` / `merge` all live in an impl -/// bounded on `S::Counter: Copy + Ord + From + Into + AddAssign`. -/// Four counter types reach `CMSHeap` through a public constructor and two of -/// them fail that bound: +/// bounded on the counter converting into its heap count (`TopKCounter`): /// -/// | counter | `Ord` | `Into` | operations | -/// | --- | --- | --- | --- | -/// | `i32`, `i64` | yes | yes | full | -/// | `i128` | yes | **no** | none | -/// | `f64` | **no** | **no** | none | +/// | counter | heap count | operations | +/// | --- | --- | --- | +/// | `i32`, `i64` | `i64` | full | +/// | `f64` | `f64` | full | +/// | `i128` | `i64`, no `Into` | none | /// -/// So `CMSHeap>`, `CMSHeap>`, -/// `CMSHeap` and `CMSHeap` are -/// *constructible but inert* on both hashing paths — eight instances that -/// allocate a sketch and a heap and can then do nothing but report their -/// dimensions. +/// So `CMSHeap>`, `CMSHeap` and +/// `CMSHeap` are *constructible but inert* on both hashing +/// paths — six instances that allocate a sketch and a heap and can then do +/// nothing but report their dimensions. /// /// # What this test is, and what it is not /// @@ -789,7 +786,7 @@ fn heap_count(items: &[asap_sketchlib::HHItem], key: &DataInput) -> Option /// A `trybuild` fixture would close that hole and is the right thing if these /// instances ever matter; it was not added here because it means a new /// dev-dependency for a set of instances that, by construction, have no -/// behaviour to protect. The coverage matrix lists all eight under **gap**, not +/// behaviour to protect. The coverage matrix lists all six under **gap**, not /// under covered instances, and this test is filed as `structural /// (constructibility only)`. /// @@ -839,20 +836,6 @@ fn cmsheap_inert_instances_construct_and_report_their_geometry() { 3, 4096 ); - inert!( - Vector2D, - RegularPath, - CMSHeap::, RegularPath>::new(3, 4096, 32), - 3, - 4096 - ); - inert!( - Vector2D, - FastPath, - CMSHeap::, FastPath>::new(3, 4096, 32), - 3, - 4096 - ); inert!( QuickMatrixI128, RegularPath, From 7257fa48416e51f46b81105f057c62b6e0cd886d Mon Sep 17 00:00:00 2001 From: GordonYuanyc Date: Fri, 16 Oct 2026 08:07:01 +0000 Subject: [PATCH 4/7] feat(csheap): f64 heap counts and the CSHeap delta (0a 80) An f64 CSHeap keeps the unrounded median as its heap count; integer counters truncate toward zero and saturate (CsHeapCounter). CSHeapDelta adds cells and replaces the heap, all or nothing. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + asapv1_golden | 2 +- docs/api/api_cs_heap.md | 66 +++++--- docs/asapv1_wire_format.md | 4 +- docs/tests.md | 9 ++ src/sketches/countsketch_topk.rs | 118 +++++++++++--- src/sketches/countsketch_topk/delta.rs | 204 +++++++++++++++++++++++++ src/sketches/countsketch_topk/wire.rs | 18 +-- src/sketches/mod.rs | 2 +- tests/asapv1_golden.rs | 59 ++++++- tests/e2e/matrix_instances.rs | 2 +- 11 files changed, 429 insertions(+), 57 deletions(-) create mode 100644 src/sketches/countsketch_topk/delta.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 71b27e49..fc530f0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,8 @@ signals a backwards-compatible change. `Count::merge_delta`, which changes nothing when it fails. - **`CMSHeapDelta`**, the ASAPv1 CMSHeap delta (`0x03 0x80`), and `f64` CMSHeap insert and merge, with unrounded `f64` heap counts. +- **`CSHeapDelta`**, the ASAPv1 CSHeap delta (`0x0a 0x80`), and unrounded + `f64` heap counts for an `f64` CSHeap (`CsHeapCounter`). ### Changed diff --git a/asapv1_golden b/asapv1_golden index 8f225608..396b54dd 160000 --- a/asapv1_golden +++ b/asapv1_golden @@ -1 +1 @@ -Subproject commit 8f225608466e3dee8b11754546d3b42a44239184 +Subproject commit 396b54ddf276aa43668c89d13d7d8592d0a927f0 diff --git a/docs/api/api_cs_heap.md b/docs/api/api_cs_heap.md index eb9cdbde..ab44fb8e 100644 --- a/docs/api/api_cs_heap.md +++ b/docs/api/api_cs_heap.md @@ -10,6 +10,11 @@ Count Sketch with integrated heavy-hitter heap (`HHHeap`) for top-k tracking. - `CSHeap, M = RegularPath, H = DefaultXxHasher>` +The heap is an `HHHeap` whose count type `C` is `S::Counter`'s +`TopKCounter::HeapCount`: `f64` over `f64` counters, `i64` over `i32`, `i64` +and `i128`. Each entry's count is the sketch's median estimate, through +`CsHeapCounter::heap_count`. + ## Constructors ```rust @@ -34,7 +39,7 @@ fn estimate(&self, key: &DataInput) -> f64 fn rows(&self) -> usize fn cols(&self) -> usize fn cs(&self) -> &Count -fn heap(&self) -> &HHHeap +fn heap(&self) -> &HHHeap ``` ## Merge @@ -55,14 +60,14 @@ These produce/consume the **ASAPv1** wire envelope (kind `0x0a 0x00`) — see th on every `CSHeap`: the impl exists only for wire-eligible configs `CSHeap, Mode, H>` where `T` is `i32`, `i64` or `f64` (`CsWireCounter`), `Mode` is `FastPath` or `RegularPath` (`CsWireMode`), and -`H: HashProfile`. Heap counts are written as `i64` under every counter type. An -`i128` or non-`Vector2D` sketch must be converted first (only you know if the -mapping is lossless). +`H: HashProfile`. An `i128` or non-`Vector2D` sketch must be converted first +(only you know if the mapping is lossless). A sketch travels as the base matrix plus the heap's entries: the metadata carries `rows`, `cols`, `counter_type`, `mode`, the heap capacity `heap_size` -and the heap's `key_type`, and the payload is `[counts, keys, heap_counts]`. -The heap's digest index is rebuilt on load, so no index reaches the wire, and +and the heap's `key_type`, and the payload is `[counts, keys, heap_counts]`, +with `heap_counts` `f64` under `counter_type` `"f64"` and `i64` otherwise. The +heap's digest index is rebuilt on load, so no index reaches the wire, and `heap_size` never sizes an allocation on decode. The base matrix is bound by the same `1 <= rows <= 20` (`MATRIX_MAX_ROWS`, the seed list length) the stand-alone sketch is: past that, the regular path gives row `r` and row @@ -75,13 +80,37 @@ and the `keys` array is homogeneous in it. A heap whose keys mix variants, or holds an `I128` / `U128` key, does not serialize. An empty heap emits `key_type = "u64"`. -Entries are emitted in descending count, ties broken by a total order over the -key, so a decoded sketch re-serializes byte-identically. The decoder rejects -entries in any other order. +Entries are emitted in descending count (`f64::total_cmp` for `f64` counts), +ties broken by a total order over the key, so a decoded sketch re-serializes +byte-identically. The decoder rejects entries in any other order. `CountL2HH` is a different algorithm with its own ASAPv1 kind (`0x19 0x00`); see [the Count Sketch API doc](./api_count_sketch.md). +## Deltas + +```rust +CSHeapDelta + +fn new(rows: usize, cols: usize, indices: Vec, deltas: Vec, + heap: HHHeap) -> Result +fn serialize_to_bytes(&self) -> Result, RmpEncodeError> +fn deserialize_from_bytes(bytes: &[u8]) -> Result + +// on CSHeap, Mode, H> +fn merge_delta(&mut self, delta: &CSHeapDelta) + -> Result<(), String> +``` + +A `CSHeapDelta` is the ASAPv1 delta kind `0x0a 0x80`, for the same configs as +the state serialization: the changed cells as `CountSketchDelta`'s (strictly +ascending row-major indices below `rows * cols`, each with a non-zero value) +and the producer's whole heap, encoded as the state's heap is. `new` and the +decoder refuse anything else. `merge_delta` adds the cells to the counters and +replaces the heap with the delta's, its key type included; a delta of other +dimensions or heap capacity, or an integer add that overflows, fails and leaves +the sketch unchanged. + ## Examples ```rust @@ -96,16 +125,17 @@ assert!(sk.estimate(&DataInput::Str("flow")) >= 1.0); - Estimate semantics follow Count Sketch and may be non-integer. - Merge requires matching dimensions and compatible type parameters. -- **Heap counts are a saturating, truncating view of the estimate.** The insert - path stores `cs_heap_count(estimate)`, where the estimate is the row median as - `f64` and the heap holds `i64`. The conversion saturates at `i64::MAX` / - `i64::MIN` (and maps `NaN` to `0`) rather than wrapping, because a wrapped - negative count would corrupt the heap's ordering. An `i128`-backed sketch can - genuinely hold counts past `i64::MAX` — `insert_many(key, i128)` accepts them, - and the sketch keeps them — but its heap entry clamps. Above `2^53` the - estimate has already lost precision inside `Count::estimate`. +- **Over integer counters, heap counts are a saturating, truncating view of the + estimate.** The insert path stores `cs_heap_count(estimate)`, where the + estimate is the row median as `f64` and the heap holds `i64`. The conversion + saturates at `i64::MAX` / `i64::MIN` (and maps `NaN` to `0`) rather than + wrapping, because a wrapped negative count would corrupt the heap's ordering. + An `i128`-backed sketch can genuinely hold counts past `i64::MAX` — + `insert_many(key, i128)` accepts them, and the sketch keeps them — but its + heap entry clamps. Above `2^53` the estimate has already lost precision + inside `Count::estimate`. - Every storage backend is insertable, `i128` included: the bound is - `S::Counter: CountSketchCounter`, which `i32`, `i64` and `i128` all satisfy. + `S::Counter: CsHeapCounter`, which `i32`, `i64`, `i128` and `f64` all satisfy. This is *not* true of `CMSHeap`; see its page. ## Status diff --git a/docs/asapv1_wire_format.md b/docs/asapv1_wire_format.md index df85b5c3..5b209377 100644 --- a/docs/asapv1_wire_format.md +++ b/docs/asapv1_wire_format.md @@ -26,7 +26,7 @@ If the doc feels long, these sections carry the key points: ## Status -- **Implemented (Rust).** Every kind_id the Section 1 registry marks *implemented* serializes through the shared `message_pack_format::envelope` module per this spec: HLL (three estimators), Count-Min with its delta (`CountMinDelta`), CMSHeap with its delta (`CMSHeapDelta`), Count Sketch with its delta (`sketches::CountSketchDelta`), CSHeap, DDSketch, KLL (compact + dynamic), Hydra (five counter variants), Elastic, Coco, UniformSampling, KMV, UnivMon, UnivMon Optimized, ExponentialHistogram, EHSketchList, Bloom, Space-Saving, CountL2HH and UnivMon-Q. +- **Implemented (Rust).** Every kind_id the Section 1 registry marks *implemented* serializes through the shared `message_pack_format::envelope` module per this spec: HLL (three estimators), Count-Min with its delta (`CountMinDelta`), CMSHeap with its delta (`CMSHeapDelta`), Count Sketch with its delta (`sketches::CountSketchDelta`), CSHeap with its delta (`CSHeapDelta`), DDSketch, KLL (compact + dynamic), Hydra (five counter variants), Elastic, Coco, UniformSampling, KMV, UnivMon, UnivMon Optimized, ExponentialHistogram, EHSketchList, Bloom, Space-Saving, CountL2HH and UnivMon-Q. - **Self-describing.** The hash-spec metadata is derived from the hasher's `HashProfile` (read live, never hardcoded), so the bytes truthfully describe how a sketch was hashed; custom hash profiles are supported (Section 2). - **Byte-level encoding** is pinned in Section 4; the resolved decisions are summarized at the end. - **`sketchlib-go`** is aligned separately (see Cross-language contract). @@ -207,7 +207,7 @@ This registry is the master list of algorithms still to design payloads for, and | `0x08 0x80` | SetAggregator | SetAggregator delta | Section 3.22 | designed | | `0x09 0x00` | - | - | TBD | retired / permanently reserved, never reassign | | `0x0a 0x00` | Count-Sketch-with-heap (CSHeap) | Count Sketch + top-k heap | Section 3.10 | implemented | -| `0x0a 0x80` | Count-Sketch-with-heap (CSHeap) | CSHeap delta | Section 3.23 | designed | +| `0x0a 0x80` | Count-Sketch-with-heap (CSHeap) | CSHeap delta | Section 3.23 | implemented | | `0x0b 0x00` | Elastic | Heavy/light | Section 3.11 | implemented | | `0x0c 0x00` | Coco | CocoSketch | Section 3.12 | implemented | | `0x0d 0x00` | UniformSampling (`Unstable`) | Priority sampling | Section 3.13 | implemented | diff --git a/docs/tests.md b/docs/tests.md index 93fe9a6e..647dcf62 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -300,11 +300,15 @@ Test file: [`src/sketches/countsketch_topk.rs`](../src/sketches/countsketch_topk Wire tests: [`src/sketches/countsketch_topk/wire.rs`](../src/sketches/countsketch_topk/wire.rs) +Delta tests: [`src/sketches/countsketch_topk/delta.rs`](../src/sketches/countsketch_topk/delta.rs) + | test_name | test_description | what_is_tested | | --- | --- | --- | | `insert_and_estimate` | Repeated inserts increment `Count` estimate for one key. | Inserts `"hello"` 5 times into `CSHeap::new(5,256,10)` and verifies estimate is `5.0` within `1e-9`. | | `heap_tracks_top_k` | Heap keeps highest-frequency keys within top-k capacity. | Inserts keys `1..5` with frequencies `100,200,300,400,500` into `top_k=3` sketch and verifies heap counts are exactly `[300,400,500]`. | | `merge_reconciles_heaps` | Merge combines counters and refreshes heap counts from merged sketch. | Merges two sketches containing `"merge_key"` counts `10` and `20`, then verifies estimate is `30.0` and heap count is `30`. | +| `integer_heap_counts_truncate_the_median_toward_zero` | An integer sketch's heap holds its median truncated toward zero. | For a `2x64` `i64` sketch whose rows read `-2` and `-3` for `"flow"`, verifies the estimate is `-2.5` and the heap count `-2`. | +| `f64_heap_counts_are_the_unrounded_median` | An `f64` sketch's heap holds the unrounded median. | On both paths, verifies `insert_many("flow", -2.25)` leaves heap count `-2.25` and a merge with a copy leaves `-4.5`. | | `insert_many_updates_estimate_and_heap` | `insert_many` updates estimate and heap entry consistently. | Calls `insert_many("many", 17)` and verifies estimate `17.0` plus heap entry count equals estimated count. | | `bulk_insert_updates_multiple_keys` | `bulk_insert` updates multiple keys and heavy-hitter counts correctly. | Inserts stream `[7,8,7,9,7]`, verifies estimate for key `7` is `3.0`, and heap count for key `7` matches estimate cast to integer. | | `clear_heap_keeps_cs_counters` | Clearing heap does not clear `Count` counters. | After `insert_many("persist",5)`, calls `clear_heap()`, verifies estimate remains `5.0`, then one more insert repopulates heap with updated estimate count. | @@ -339,6 +343,9 @@ Wire tests: [`src/sketches/countsketch_topk/wire.rs`](../src/sketches/countsketc | `cs_heap_custom_hasher_profile_round_trips_and_is_self_describing` | The metadata describes the hasher that built the sketch rather than a hardcoded profile. | For a `3x8` sketch with a heap of `4` over a hasher declaring its own `HashProfile`, verifies the counters and both heap entries round-trip, the bytes differ from the standard-profile sketch's over the same keys, and a standard-profile decode rejects them. | | `cs_heap_refuses_a_heap_size_the_metadata_cannot_carry` | A `heap_size` past the metadata's `u32` field fails the encode rather than truncating. | Verifies a `2x4` sketch built at a heap capacity of `1 << 40` fails to serialize with a message naming the "exceeds the u32 metadata field" rule. | | `cs_heap_rejects_too_many_rows` | The base matrix carries the seed list's row bound. | Verifies a sketch past `MATRIX_MAX_ROWS` fails to serialize, that a crafted envelope of that geometry fails to decode with a message naming `MATRIX_MAX_ROWS`, and that the boundary row count still serializes. | +| `decode_rejects_an_index_past_the_matrix` | A `0x0a 0x80` cell index must be below `rows * cols`. | For a `2x4` `i64` delta with heap size `2`, verifies indices `[0, 7]` decode and index `8` fails. | +| `decode_rejects_another_kind` | A delta decodes only under its own kind_id. | Verifies the `csheap_i64_regular_2x4_delta` golden decodes and fails with its kind_id relabelled `0x0a 0x00`. | +| `merge_delta_rejects_without_changing_the_state` | A rejected delta leaves the matrix and the heap unchanged. | For a `2x4` state with heap size `2` holding `"kept"`, verifies a `4x2` delta, a delta with heap size `3` and a delta whose last cell overflows `i64::MIN` each fail, leaving the cells and the one-entry heap as they were. | ### CountL2HH @@ -2161,6 +2168,8 @@ Test file: [`tests/asapv1_golden.rs`](../tests/asapv1_golden.rs) | `cs_i64_fast_2x4_matches_golden` | The fast-path mode reaches the bytes, so the same matrix encodes differently per path. | Verifies `Count::, FastPath>::from_storage` over `[[0, 127, 128, 65536], [-1, -33, -32768, -2147483648]]` serializes to `asapv1_golden/cs_i64_fast_2x4.hex`, that decoding it recovers the eight cells and re-serializes identically, and that the fixture differs from `cs_i64_regular_2x4.hex` built from the same matrix. | | `cs_i64_regular_2x4_delta_matches_golden` | A known i64 Count Sketch delta serializes to its golden bytes and merges into its state golden. | Verifies `CountSketchDelta::::new(2, 4, [0, 1, 2, 4, 6, 7], [-32, -128, 255, -129, 65535, -2147483649])` serializes to `asapv1_golden/cs_i64_regular_2x4_delta.hex`, that the fixture decodes and re-serializes identically, and that merged into `cs_i64_regular_2x4` it gives `[-32, -1, 383, 65536, -130, -33, 32767, -4294967297]`. | | `cs_i64_regular_2x4_matches_golden` | A known signed Count-Sketch matrix serializes to its checked-in golden bytes. | Verifies `Count::, RegularPath>::from_storage` over `[[0, 127, 128, 65536], [-1, -33, -32768, -2147483648]]` — sweeping the msgpack integer widths in both directions — serializes to `asapv1_golden/cs_i64_regular_2x4.hex`, and that decoding the fixture reports `rows()` `2`, `cols()` `4`, the eight cells, and a byte-identical re-serialization. | +| `csheap_f64_fast_2x4_strkeys_matches_golden` | `f64` heap counts are the unrounded signed median, a tie broken by the key. | Verifies an `f64` fast-path `CSHeap` over the `2x4` f64 Count Sketch matrix with heap size `4` holding `{"alpha": 63.5625, "aa": -0.75, "beta": -0.75, "gamma": -16384.25}` serializes to `asapv1_golden/csheap_f64_fast_2x4_strkeys.hex`, and that the decode holds that matrix and those entries and re-serializes identically. | +| `csheap_i64_regular_2x4_delta_matches_golden` | A known CSHeap delta serializes to its golden bytes and, merged, replaces the state's heap. | Verifies `CSHeapDelta::::new(2, 4, [0, 1, 2, 4, 6, 7], [-32, -128, 255, -129, 65535, -2147483649], heap)` with heap size `5` holding `i64` keys `{-1: 65536, 7: -33, -129: -4294967297}` serializes to `asapv1_golden/csheap_i64_regular_2x4_delta.hex`, that the fixture decodes and re-serializes identically, and that merged into the `string`-keyed `csheap_i64_regular_2x4_strkeys` it gives `[-32, -1, 383, 65536, -130, -33, 32767, -4294967297]` and exactly the delta's heap. | | `csheap_i64_regular_2x4_strkeys_matches_golden` | A CSHeap serializes to its golden bytes. | Verifies the `2x4` Count Sketch matrix with heap size `5` holding `{"alpha": 4294967296, "beta": 127, "delta": 127, "gamma": -33}` serializes to `asapv1_golden/csheap_i64_regular_2x4_strkeys.hex`, and that the decode holds that matrix and those entries and re-serializes identically. | | `hll_classic_p12_matches_golden` | A known P12 Classic register pattern serializes to its golden bytes on both the portable and native paths. | Builds `HllSketch::from_raw(HllVariant::Regular, 12, regs, 0.0, 0.0, 0.0)` where `regs` is 4,096 zeros with `[0] = 1`, `[1] = 7`, `[100] = 42` and `[4095] = 3`, verifies `to_msgpack()` equals `asapv1_golden/hll_classic_p12.hex`, that `HyperLogLogP12::::deserialize_from_bytes` on the fixture recovers those registers and re-serializes identically, and that `HllSketch::from_msgpack` reports the same registers, `precision` `12` and `HllVariant::Regular`. | | `hll_ertl_mle_p12_matches_golden` | The Datafusion variant tag reaches the bytes for the same P12 register pattern. | Builds `HllSketch::from_raw(HllVariant::Datafusion, 12, regs, 0.0, 0.0, 0.0)` over the shared P12 pattern (`[0] = 1`, `[1] = 7`, `[100] = 42`, `[4095] = 3` in 4,096 registers), verifies `to_msgpack()` equals `asapv1_golden/hll_ertl_mle_p12.hex`, that `HyperLogLogP12::::deserialize_from_bytes` recovers the registers and re-serializes identically, and that `HllSketch::from_msgpack` reports `HllVariant::Datafusion`. | diff --git a/src/sketches/countsketch_topk.rs b/src/sketches/countsketch_topk.rs index c3c5d0ea..6b9a3dc1 100644 --- a/src/sketches/countsketch_topk.rs +++ b/src/sketches/countsketch_topk.rs @@ -15,18 +15,23 @@ use crate::sketches::countsketch::CountSketchCounter; use crate::{ Count, DataInput, DefaultMatrixI32, DefaultMatrixI64, DefaultMatrixI128, DefaultXxHasher, FastPath, FixedMatrix, HHHeap, MatrixStorage, QuickMatrixI64, QuickMatrixI128, RegularPath, - SketchHasher, Vector1D, Vector2D, compute_median_inline_f64, heap_item_to_sketch_input, + SketchHasher, TopKCounter, Vector1D, Vector2D, compute_median_inline_f64, + heap_item_to_sketch_input, }; +mod delta; pub(crate) mod l2hh_wire; mod wire; -/// The count a `CSHeap` entry carries for a Count Sketch estimate. +pub use delta::CSHeapDelta; + +/// The count a `CSHeap` entry over integer counters carries for a Count Sketch +/// estimate. /// /// `Count::estimate` returns the row **median** as `f64`, because a Count /// Sketch estimate is signed and is a median rather than a counter read. The -/// heap stores `i64`, so a conversion is unavoidable and its semantics are part -/// of the API rather than an accident of a cast: +/// integer counters' heap stores `i64`, so a conversion is unavoidable and its +/// semantics are part of the API rather than an accident of a cast: /// /// - **Saturating**, not wrapping. Rust's `f64 as i64` saturates at /// `i64::MIN`/`i64::MAX` and maps `NaN` to `0`; this function names that @@ -49,28 +54,66 @@ pub fn cs_heap_count(estimate: f64) -> i64 { estimate as i64 } +/// The heap count type of a `CSHeap` over storage `S`. +type HeapCountOf = <::Counter as TopKCounter>::HeapCount; + +/// A Count Sketch counter type a `CSHeap` keeps a heap beside: the heap count +/// of a median estimate is [`cs_heap_count`]'s for `i32`, `i64` and `i128`, and +/// the unrounded median for `f64`. +pub trait CsHeapCounter: CountSketchCounter + TopKCounter { + /// The heap count of a median `estimate`. + fn heap_count(estimate: f64) -> Self::HeapCount; +} + +impl CsHeapCounter for i32 { + fn heap_count(estimate: f64) -> i64 { + cs_heap_count(estimate) + } +} + +impl CsHeapCounter for i64 { + fn heap_count(estimate: f64) -> i64 { + cs_heap_count(estimate) + } +} + +impl CsHeapCounter for i128 { + fn heap_count(estimate: f64) -> i64 { + cs_heap_count(estimate) + } +} + +impl CsHeapCounter for f64 { + fn heap_count(estimate: f64) -> f64 { + estimate + } +} + const DEFAULT_TOP_K: usize = 32; const DEFAULT_ROW_NUM: usize = 3; const DEFAULT_COL_NUM: usize = 4096; /// A Count Sketch paired with a top-k heavy-hitter heap. /// -/// Generic over the same type parameters as [`Count`]. +/// Generic over the same type parameters as [`Count`]. The heap's counts are +/// `f64` over `f64` counters and `i64` otherwise ([`CsHeapCounter`]). #[derive(Clone)] pub struct CSHeap< S: MatrixStorage = Vector2D, Mode = RegularPath, H: SketchHasher = DefaultXxHasher, -> { +> where + S::Counter: TopKCounter, +{ cs: Count, - heap: HHHeap, + heap: HHHeap>, } // -- Construction for Vector2D-backed storage -------------------------------- impl CSHeap, M, H> where - T: CountSketchCounter, + T: CsHeapCounter, { /// Creates a new `CSHeap` with the given CS dimensions and heap capacity. pub fn new(rows: usize, cols: usize, top_k: usize) -> Self { @@ -85,7 +128,7 @@ where impl CSHeap where - S::Counter: CountSketchCounter, + S::Counter: CsHeapCounter, { /// Creates a `CSHeap` from a pre-built storage backend. pub fn from_storage(storage: S, top_k: usize) -> Self { @@ -202,7 +245,7 @@ impl Default for CSHeap { impl CSHeap where - S::Counter: CountSketchCounter, + S::Counter: CsHeapCounter, { /// Returns a reference to the internal Count Sketch. pub fn cs(&self) -> &Count { @@ -215,12 +258,12 @@ where } /// Returns a reference to the heavy-hitter heap. - pub fn heap(&self) -> &HHHeap { + pub fn heap(&self) -> &HHHeap> { &self.heap } /// Returns a mutable reference to the heavy-hitter heap. - pub fn heap_mut(&mut self) -> &mut HHHeap { + pub fn heap_mut(&mut self) -> &mut HHHeap> { &mut self.heap } @@ -246,14 +289,14 @@ where impl CSHeap where - S::Counter: CountSketchCounter, + S::Counter: CsHeapCounter, { /// Inserts a single observation and updates the top-k heap. #[inline] pub fn insert(&mut self, key: &DataInput) { self.cs.insert(key); let est = self.cs.estimate(key); - self.heap.update(key, cs_heap_count(est)); + self.heap.update(key, S::Counter::heap_count(est)); } /// Inserts an observation with the given count and updates the top-k heap. @@ -261,7 +304,7 @@ where pub fn insert_many(&mut self, key: &DataInput, many: S::Counter) { self.cs.insert_many(key, many); let est = self.cs.estimate(key); - self.heap.update(key, cs_heap_count(est)); + self.heap.update(key, S::Counter::heap_count(est)); } /// Inserts a batch of observations, updating the heap after each. @@ -294,7 +337,7 @@ where for key in candidate_keys { let key_ref = heap_item_to_sketch_input(&key); let est = self.cs.estimate(&key_ref); - self.heap.update(&key_ref, cs_heap_count(est)); + self.heap.update(&key_ref, S::Counter::heap_count(est)); } } } @@ -304,14 +347,14 @@ where impl CSHeap where S: MatrixStorage + crate::FastPathHasher, - S::Counter: CountSketchCounter, + S::Counter: CsHeapCounter, { /// Inserts a single observation using fast-path hashing and updates the heap. #[inline] pub fn insert(&mut self, key: &DataInput) { self.cs.insert(key); let est = self.cs.estimate(key); - self.heap.update(key, cs_heap_count(est)); + self.heap.update(key, S::Counter::heap_count(est)); } /// Inserts an observation with the given count using fast-path hashing. @@ -319,7 +362,7 @@ where pub fn insert_many(&mut self, key: &DataInput, many: S::Counter) { self.cs.insert_many(key, many); let est = self.cs.estimate(key); - self.heap.update(key, cs_heap_count(est)); + self.heap.update(key, S::Counter::heap_count(est)); } /// Inserts a batch of observations using fast-path hashing. @@ -349,7 +392,7 @@ where for key in candidate_keys { let key_ref = heap_item_to_sketch_input(&key); let est = self.cs.estimate(&key_ref); - self.heap.update(&key_ref, cs_heap_count(est)); + self.heap.update(&key_ref, S::Counter::heap_count(est)); } } } @@ -361,7 +404,7 @@ mod tests { use crate::test_utils::sample_zipf_u64; use std::collections::{HashMap, HashSet}; - fn heap_count_for_key(heap: &HHHeap, key: &DataInput) -> Option { + fn heap_count_for_key(heap: &HHHeap, key: &DataInput) -> Option { heap.heap() .iter() .find(|item| heap_item_to_sketch_input(&item.key) == *key) @@ -488,6 +531,39 @@ mod tests { assert_eq!(heap_item.count, 30); } + /// An `f64` sketch's heap holds the unrounded median after an insert and + /// after a merge, on both paths. + #[test] + fn f64_heap_counts_are_the_unrounded_median() { + let key = DataInput::Str("flow"); + let mut regular = CSHeap::, RegularPath>::new(3, 64, 4); + regular.insert_many(&key, -2.25); + assert_eq!(heap_count_for_key(regular.heap(), &key), Some(-2.25)); + regular.merge(®ular.clone()); + assert_eq!(heap_count_for_key(regular.heap(), &key), Some(-4.5)); + + let mut fast = CSHeap::, FastPath>::new(3, 64, 4); + fast.insert_many(&key, -2.25); + assert_eq!(heap_count_for_key(fast.heap(), &key), Some(-2.25)); + fast.merge(&fast.clone()); + assert_eq!(heap_count_for_key(fast.heap(), &key), Some(-4.5)); + } + + /// An integer sketch's heap holds its median truncated toward zero: rows + /// reading `-2` and `-3` give `-2.5`, held as `-2`. + #[test] + fn integer_heap_counts_truncate_the_median_toward_zero() { + let key = DataInput::Str("flow"); + let mut sketch = CSHeap::, RegularPath>::new(2, 64, 4); + sketch.insert_many(&key, -2); + let row = &mut sketch.cs_mut().as_storage_mut()[1]; + let cell = row.iter_mut().find(|cell| **cell != 0).expect("key cell"); + *cell = *cell / 2 * 3; + sketch.insert_many(&key, 0); + assert_eq!(sketch.estimate(&key), -2.5); + assert_eq!(heap_count_for_key(sketch.heap(), &key), Some(-2)); + } + #[test] fn insert_many_updates_estimate_and_heap() { // Verifies insert_many updates estimate and heap count consistently. diff --git a/src/sketches/countsketch_topk/delta.rs b/src/sketches/countsketch_topk/delta.rs new file mode 100644 index 00000000..709c6813 --- /dev/null +++ b/src/sketches/countsketch_topk/delta.rs @@ -0,0 +1,204 @@ +//! The CSHeap delta, ASAPv1 kind `0x0a 0x80` (`docs/asapv1_wire_format.md` +//! §3.23). Its metadata is the state's (§3.10); its payload is +//! `[indices, deltas, keys, heap_counts]`: the changed cells and the producer's +//! whole heap. + +use std::marker::PhantomData; + +use rmp_serde::{decode::Error as RmpDecodeError, encode::Error as RmpEncodeError}; +use serde::{Deserialize, Serialize}; + +use crate::message_pack_format::delta::{add_cells, check_cells}; +use crate::message_pack_format::envelope; +use crate::sketches::countminsketch_topk::heap_wire::{ + DeltaCells, check_geometry, decode_metadata, decode_payload, encode_metadata, encode_payload, + heap_entries, rebuild_heap, wire_key_type, +}; +use crate::sketches::countsketch::{CsWireCounter, CsWireMode}; +use crate::{ + DefaultXxHasher, HHHeap, HashProfile, RegularPath, SketchHasher, TopKCounter, Vector2D, +}; + +use super::{CSHeap, CsHeapCounter}; + +/// CSHeap delta kind_id: the state's `0x0a 0x00` with the variant's top bit set. +const CS_HEAP_DELTA_KIND: &[u8] = &[0x0a, 0x80]; + +/// A change to a `rows x cols` [`CSHeap`] state: each value of `deltas` is +/// added to the cell its position names in `indices`, a row-major index +/// `row * cols + col`, and `heap` replaces the state's heap. Indices are +/// strictly ascending and below `rows * cols`, and no delta is zero. +#[derive(Clone, Debug)] +pub struct CSHeapDelta { + rows: usize, + cols: usize, + indices: Vec, + deltas: Vec, + heap: HHHeap, + _config: PhantomData<(Mode, H)>, +} + +impl CSHeapDelta +where + T: CsWireCounter + TopKCounter + Serialize + for<'de> Deserialize<'de>, + Mode: CsWireMode, + H: SketchHasher + HashProfile, +{ + /// Builds a delta over a `rows x cols` matrix from the changed cells and the + /// producer's heap. Fails when the geometry is not wire-eligible or the cells + /// break the rules above. + pub fn new( + rows: usize, + cols: usize, + indices: Vec, + deltas: Vec, + heap: HHHeap, + ) -> Result { + check_geometry("CSHeap", rows, cols)?; + check_cells(&indices, &deltas, ..rows as u64 * cols as u64) + .map_err(|e| format!("CSHeap delta: {e}"))?; + Ok(Self { + rows, + cols, + indices, + deltas, + heap, + _config: PhantomData, + }) + } + + /// Serializes the delta into an ASAPv1 MessagePack envelope. Fails as the + /// state's encode does on the heap: keys that mix `HeapItem` variants, a + /// 128-bit key, or a `heap_size` past `u32`. + pub fn serialize_to_bytes(&self) -> Result, RmpEncodeError> { + let entries = heap_entries(&self.heap); + let key_type = wire_key_type(&entries)?; + let metadata = encode_metadata::( + "CSHeap", + self.rows, + self.cols, + T::COUNTER_TYPE, + Mode::MODE, + self.heap.capacity(), + key_type, + )?; + let cells = DeltaCells { + indices: self.indices.clone(), + deltas: self.deltas.clone(), + }; + let payload = encode_payload(key_type, cells, &entries)?; + Ok(envelope::encode(CS_HEAP_DELTA_KIND, &metadata, &payload)) + } + + /// Deserializes a delta from an ASAPv1 MessagePack envelope. + pub fn deserialize_from_bytes(bytes: &[u8]) -> Result { + let (kind_id, metadata, payload) = + envelope::split(bytes).map_err(RmpDecodeError::Uncategorized)?; + if kind_id != CS_HEAP_DELTA_KIND { + return Err(RmpDecodeError::Uncategorized(format!( + "CSHeap delta kind_id mismatch: stored {kind_id:?}, expected {CS_HEAP_DELTA_KIND:?}" + ))); + } + let shape = decode_metadata::("CSHeap", metadata, T::COUNTER_TYPE, Mode::MODE)?; + let (cells, entries) = decode_payload::, _>(&shape.key_type, payload)?; + let heap = rebuild_heap(shape.heap_size, entries)?; + Self::new(shape.rows, shape.cols, cells.indices, cells.deltas, heap) + .map_err(RmpDecodeError::Uncategorized) + } +} + +impl CSHeap, Mode, H> +where + T: CsWireCounter + CsHeapCounter + Serialize + for<'de> Deserialize<'de>, + Mode: CsWireMode, + H: SketchHasher + HashProfile, +{ + /// Adds a delta's cells to the counters and replaces the heap with the + /// delta's. Fails, changing nothing, when the delta is for another geometry + /// or heap capacity, or an integer add overflows. + pub fn merge_delta(&mut self, delta: &CSHeapDelta) -> Result<(), String> { + let (rows, cols, heap_size) = (self.rows(), self.cols(), self.heap.capacity()); + if (rows, cols, heap_size) != (delta.rows, delta.cols, delta.heap.capacity()) { + return Err(format!( + "CSHeap delta: metadata mismatch: a {}x{} delta with heap_size {} for a \ + {rows}x{cols} sketch with heap_size {heap_size}", + delta.rows, + delta.cols, + delta.heap.capacity() + )); + } + add_cells( + self.cs.as_storage_mut().as_mut_slice(), + &delta.indices, + &delta.deltas, + ) + .map_err(|e| format!("CSHeap delta: {e}"))?; + self.heap = delta.heap.clone(); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message_pack_format::envelope::hex; + use crate::{DataInput, HeapItem}; + + type Delta = CSHeapDelta; + type Sketch = CSHeap, RegularPath>; + + const DELTA: &str = include_str!("../../../asapv1_golden/csheap_i64_regular_2x4_delta.hex"); + + /// A 2x4 `i64` delta envelope, heap_size 2, around the given indices. + fn delta_bytes(indices: &[u64]) -> Vec { + let metadata = + encode_metadata::("CSHeap", 2, 4, "i64", "regular", 2, "u64").unwrap(); + let deltas = vec![-1i64; indices.len()]; + let payload = rmp_serde::to_vec(&(indices, deltas, [1u64], [-3i64])).unwrap(); + envelope::encode(CS_HEAP_DELTA_KIND, &metadata, &payload) + } + + #[test] + fn decode_rejects_an_index_past_the_matrix() { + assert!(Delta::deserialize_from_bytes(&delta_bytes(&[0, 7])).is_ok()); + assert!(Delta::deserialize_from_bytes(&delta_bytes(&[8])).is_err()); + } + + #[test] + fn decode_rejects_another_kind() { + let delta = hex(DELTA); + assert!(Delta::deserialize_from_bytes(&delta).is_ok()); + let mut state_kind = delta.clone(); + state_kind[9] = 0x00; + assert!(Delta::deserialize_from_bytes(&state_kind).is_err()); + } + + #[test] + fn merge_delta_rejects_without_changing_the_state() { + let mut heap = HHHeap::new(2); + heap.update(&DataInput::U64(7), -3); + let delta = + |heap: HHHeap, deltas: Vec| Delta::new(2, 4, vec![0, 7], deltas, heap).unwrap(); + let cells = |_, c| if c == 3 { i64::MIN } else { 0 }; + let mut state = Sketch::from_storage(Vector2D::from_fn(2, 4, cells), 2); + state.heap_mut().update(&DataInput::Str("kept"), 1); + + let transposed = Delta::new(4, 2, vec![0, 7], vec![1, 1], HHHeap::new(2)).unwrap(); + let wider = delta(HHHeap::new(3), vec![1, 1]); + let overflowing = delta(heap, vec![-1, -1]); + for rejected in [transposed, wider, overflowing] { + assert!(state.merge_delta(&rejected).is_err()); + assert_eq!( + state.cs().as_storage().as_slice(), + &[0, 0, 0, i64::MIN, 0, 0, 0, i64::MIN] + ); + assert_eq!(state.heap().len(), 1); + assert!( + state + .heap() + .find_heap_item(&HeapItem::String("kept".into())) + .is_some() + ); + } + } +} diff --git a/src/sketches/countsketch_topk/wire.rs b/src/sketches/countsketch_topk/wire.rs index 3efc4e41..03d2c607 100644 --- a/src/sketches/countsketch_topk/wire.rs +++ b/src/sketches/countsketch_topk/wire.rs @@ -13,7 +13,8 @@ //! type** (i32/i64/f64), the column-derivation **mode** (fast/regular), the heap //! capacity `heap_size` and the heap's `key_type` — all live in the metadata, so //! the payload is `[counts, keys, heap_counts]`: the base matrix packed row-major -//! followed by the heap's entries. +//! followed by the heap's entries, whose counts are `f64` under `f64` counters +//! and `i64` otherwise. //! //! [`CountL2HH`](super::CountL2HH) is a different algorithm and is not covered //! here. @@ -26,10 +27,10 @@ use crate::sketches::countminsketch_topk::heap_wire::{ decode_metadata, decode_payload, encode_metadata, encode_payload, heap_entries, rebuild_heap, wire_key_type, }; -use crate::sketches::countsketch::{CountSketchCounter, CsWireCounter, CsWireMode}; +use crate::sketches::countsketch::{CsWireCounter, CsWireMode}; use crate::{Count, HashProfile, SketchHasher, Vector2D}; -use super::CSHeap; +use super::{CSHeap, CsHeapCounter}; /// CSHeap kind_id: family `0x0a`, single algorithm variant `0x00`. const CS_HEAP_KIND: &[u8] = &[0x0a, 0x00]; @@ -39,13 +40,10 @@ const CS_HEAP_KIND: &[u8] = &[0x0a, 0x00]; // directly. impl CSHeap, Mode, H> where - // `CountSketchCounter` is required by `Count::from_storage`; `AddAssign` by - // `Vector2D: MatrixStorage`. Neither is used by the bodies below. - T: CsWireCounter - + CountSketchCounter - + std::ops::AddAssign - + Serialize - + for<'de> Deserialize<'de>, + // `CsHeapCounter` carries `Count::from_storage`'s `CountSketchCounter` and + // the heap's `TopKCounter`; `AddAssign` is required by + // `Vector2D: MatrixStorage`. + T: CsWireCounter + CsHeapCounter + std::ops::AddAssign + Serialize + for<'de> Deserialize<'de>, Mode: CsWireMode, H: SketchHasher + HashProfile, { diff --git a/src/sketches/mod.rs b/src/sketches/mod.rs index e1372430..52d19880 100644 --- a/src/sketches/mod.rs +++ b/src/sketches/mod.rs @@ -110,7 +110,7 @@ pub mod countminsketch_topk; pub use countminsketch_topk::{CMSHeap, CMSHeapDelta}; pub mod countsketch_topk; -pub use countsketch_topk::{CSHeap, cs_heap_count}; +pub use countsketch_topk::{CSHeap, CSHeapDelta, CsHeapCounter, cs_heap_count}; pub use countsketch_topk::{CountL2HH, l2hh_cell_for_row}; pub mod octo_delta; diff --git a/tests/asapv1_golden.rs b/tests/asapv1_golden.rs index da6e20ce..ed2e712b 100644 --- a/tests/asapv1_golden.rs +++ b/tests/asapv1_golden.rs @@ -10,9 +10,9 @@ use asap_sketchlib::sketches::CountSketchDelta; use asap_sketchlib::{ - CMSHeap, CMSHeapDelta, CSHeap, Classic, Count, CountMin, CountMinDelta, ErtlMLE, FastPath, - HHHeap, HeapCount, HeapItem, HllSketch, HllVariant, HyperLogLogHIPP12, HyperLogLogP12, KLL, - MessagePackCodec, RegularPath, Vector2D, peek_kind_id, + CMSHeap, CMSHeapDelta, CSHeap, CSHeapDelta, Classic, Count, CountMin, CountMinDelta, ErtlMLE, + FastPath, HHHeap, HeapCount, HeapItem, HllSketch, HllVariant, HyperLogLogHIPP12, + HyperLogLogP12, KLL, MessagePackCodec, RegularPath, Vector2D, peek_kind_id, }; fn decode_hex(s: &str) -> Vec { @@ -50,6 +50,8 @@ const GOLDEN_CMSHEAP_F64: &str = include_str!("../asapv1_golden/cmsheap_f64_fast const GOLDEN_CMSHEAP_DELTA: &str = include_str!("../asapv1_golden/cmsheap_i64_regular_2x3_delta.hex"); const GOLDEN_CSHEAP_STR: &str = include_str!("../asapv1_golden/csheap_i64_regular_2x4_strkeys.hex"); +const GOLDEN_CSHEAP_F64: &str = include_str!("../asapv1_golden/csheap_f64_fast_2x4_strkeys.hex"); +const GOLDEN_CSHEAP_DELTA: &str = include_str!("../asapv1_golden/csheap_i64_regular_2x4_delta.hex"); const GOLDEN_KLL_F64: &str = include_str!("../asapv1_golden/kll_f64_k200.hex"); const GOLDEN_KLL_I64: &str = include_str!("../asapv1_golden/kll_i64_k200.hex"); @@ -537,6 +539,57 @@ fn csheap_i64_regular_2x4_strkeys_matches_golden() { assert_eq!(decoded.serialize_to_bytes().expect("re-serialize"), want); } +/// `f64` heap counts are the unrounded signed median; the `-0.75` tie falls +/// to the keys. +#[test] +fn csheap_f64_fast_2x4_strkeys_matches_golden() { + type Heap = CSHeap, FastPath>; + let want = decode_hex(GOLDEN_CSHEAP_F64); + let entries: Vec<(HeapItem, f64)> = [ + ("gamma", -16384.25), + ("beta", -0.75), + ("alpha", 63.5625), + ("aa", -0.75), + ] + .into_iter() + .map(|(key, count)| (HeapItem::String(key.to_string()), count)) + .collect(); + let mut sketch = Heap::from_storage(Vector2D::from_fn(2, 4, |r, c| CS_F64_VALS[r][c]), 4); + *sketch.heap_mut() = heap_of(4, &entries); + assert_eq!(sketch.serialize_to_bytes().expect("serialize"), want); + + let decoded = Heap::deserialize_from_bytes(&want).expect("decode"); + let flat: Vec = CS_F64_VALS.iter().flatten().copied().collect(); + assert_eq!(decoded.cs().as_storage().as_slice(), flat.as_slice()); + assert_heap(decoded.heap(), 4, &entries); + assert_eq!(decoded.serialize_to_bytes().expect("re-serialize"), want); +} + +/// The delta carries `i64` keys into a `string`-keyed state: applying it adds +/// the cells and replaces the heap, `key_type` with it. +#[test] +fn csheap_i64_regular_2x4_delta_matches_golden() { + type Delta = CSHeapDelta; + let want = decode_hex(GOLDEN_CSHEAP_DELTA); + let entries = i64_entries(&[(7, -33), (-129, -4_294_967_297), (-1, 65536)]); + let indices = vec![0, 1, 2, 4, 6, 7]; + let deltas = vec![-32, -128, 255, -129, 65535, -2147483649]; + let delta = Delta::new(2, 4, indices, deltas, heap_of(5, &entries)).expect("valid"); + assert_eq!(delta.serialize_to_bytes().expect("serialize"), want); + + let decoded = Delta::deserialize_from_bytes(&want).expect("decode"); + assert_eq!(decoded.serialize_to_bytes().expect("re-serialize"), want); + + let mut state = CSHeap::, RegularPath>::deserialize_from_bytes(&decode_hex( + GOLDEN_CSHEAP_STR, + )) + .expect("state"); + state.merge_delta(&decoded).expect("merge"); + let expected = [-32, -1, 383, 65536, -130, -33, 32767, -4294967297]; + assert_eq!(state.cs().as_storage().as_slice(), expected.as_slice()); + assert_heap(state.heap(), 5, &entries); +} + // --------------------------------------------------------------------------- // KLL: build known state (k=200, seed 42, integers 1..=50 — below the level-0 // capacity, so no compaction fires and the retained set is deterministic) -> diff --git a/tests/e2e/matrix_instances.rs b/tests/e2e/matrix_instances.rs index 28e46c9a..c1695744 100644 --- a/tests/e2e/matrix_instances.rs +++ b/tests/e2e/matrix_instances.rs @@ -579,7 +579,7 @@ macro_rules! heap_matrix_test { // instances construct but have no `insert`, `estimate` or `merge` at all. // They are enumerated and pinned as inert by // `cmsheap_inert_instances_construct_and_report_their_geometry`. -// - `CSHeap`'s insert path is bounded on `S::Counter: CountSketchCounter`, +// - `CSHeap`'s insert path is bounded on `S::Counter: CsHeapCounter`, // which `i128` *does* satisfy. All six `i128` instances are fully // operational and are covered below; the previous revision of this file and // of the coverage matrix wrongly claimed they were not insertable. From 8619865d8ddbccff195ee39961d55d42ce44c504 Mon Sep 17 00:00:00 2001 From: GordonYuanyc Date: Fri, 16 Oct 2026 06:19:20 +0000 Subject: [PATCH 5/7] feat(hll): ASAPv1 HLL Classic and Ertl-MLE deltas (01 81, 01 82) HyperLogLogDelta carries changed registers as ascending indices and a bin of non-zero ranks under the state's metadata; merge_delta takes the register-wise max. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + docs/api/api_hyperloglog.md | 21 ++++ docs/asapv1_wire_format.md | 6 +- docs/tests.md | 6 ++ src/message_pack_format/envelope.rs | 4 + src/sketches/hll.rs | 2 + src/sketches/hll/delta.rs | 144 ++++++++++++++++++++++++++++ src/sketches/hll/wire.rs | 26 +++-- src/sketches/mod.rs | 4 +- tests/asapv1_golden.rs | 46 ++++++++- 10 files changed, 247 insertions(+), 16 deletions(-) create mode 100644 src/sketches/hll/delta.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index fc530f0b..4117962d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,10 @@ signals a backwards-compatible change. CMSHeap insert and merge, with unrounded `f64` heap counts. - **`CSHeapDelta`**, the ASAPv1 CSHeap delta (`0x0a 0x80`), and unrounded `f64` heap counts for an `f64` CSHeap (`CsHeapCounter`). +- **`HyperLogLogDelta`**, the ASAPv1 HLL Classic and Ertl-MLE deltas + (`0x01 0x81`, `0x01 0x82`): built with `new` from changed registers, + serialized like a sketch, and merged register-wise by `max` with + `HyperLogLogImpl::merge_delta`. ### Changed diff --git a/docs/api/api_hyperloglog.md b/docs/api/api_hyperloglog.md index 4e2752bf..58f85119 100644 --- a/docs/api/api_hyperloglog.md +++ b/docs/api/api_hyperloglog.md @@ -90,6 +90,27 @@ the register bytes (plus three HIP scalars for the HIP variant). `HyperLogLogHIP is a non-generic struct that hashes through the default functions, so it is wire-eligible under the **standard** hash profile only. +## Deltas + +```rust +HyperLogLogDelta + +fn new(indices: Vec, registers: Vec) -> Result +fn serialize_to_bytes(&self) -> Result, RmpEncodeError> +fn deserialize_from_bytes(bytes: &[u8]) -> Result + +// on HyperLogLogImpl +fn merge_delta(&mut self, delta: &HyperLogLogDelta) +``` + +A `HyperLogLogDelta` is the ASAPv1 delta kind `0x01 0x81` (Classic) or +`0x01 0x82` (Ertl-MLE), for the same configs as the state serialization; HIP +has no delta. It carries the changed registers as strictly ascending indices +below `2^p` and a new value for each, non-zero and at most `65 - p`. `new` and +the decoder refuse anything else. `merge_delta` sets each named register to the +larger of its value and the delta's; the type parameters pin the estimator, +precision and hasher, so it cannot fail. + ## Examples ```rust diff --git a/docs/asapv1_wire_format.md b/docs/asapv1_wire_format.md index 5b209377..92a44e78 100644 --- a/docs/asapv1_wire_format.md +++ b/docs/asapv1_wire_format.md @@ -26,7 +26,7 @@ If the doc feels long, these sections carry the key points: ## Status -- **Implemented (Rust).** Every kind_id the Section 1 registry marks *implemented* serializes through the shared `message_pack_format::envelope` module per this spec: HLL (three estimators), Count-Min with its delta (`CountMinDelta`), CMSHeap with its delta (`CMSHeapDelta`), Count Sketch with its delta (`sketches::CountSketchDelta`), CSHeap with its delta (`CSHeapDelta`), DDSketch, KLL (compact + dynamic), Hydra (five counter variants), Elastic, Coco, UniformSampling, KMV, UnivMon, UnivMon Optimized, ExponentialHistogram, EHSketchList, Bloom, Space-Saving, CountL2HH and UnivMon-Q. +- **Implemented (Rust).** Every kind_id the Section 1 registry marks *implemented* serializes through the shared `message_pack_format::envelope` module per this spec: HLL (three estimators) with the Classic and Ertl-MLE deltas (`HyperLogLogDelta`), Count-Min with its delta (`CountMinDelta`), CMSHeap with its delta (`CMSHeapDelta`), Count Sketch with its delta (`sketches::CountSketchDelta`), CSHeap with its delta (`CSHeapDelta`), DDSketch, KLL (compact + dynamic), Hydra (five counter variants), Elastic, Coco, UniformSampling, KMV, UnivMon, UnivMon Optimized, ExponentialHistogram, EHSketchList, Bloom, Space-Saving, CountL2HH and UnivMon-Q. - **Self-describing.** The hash-spec metadata is derived from the hasher's `HashProfile` (read live, never hardcoded), so the bytes truthfully describe how a sketch was hashed; custom hash profiles are supported (Section 2). - **Byte-level encoding** is pinned in Section 4; the resolved decisions are summarized at the end. - **`sketchlib-go`** is aligned separately (see Cross-language contract). @@ -186,8 +186,8 @@ This registry is the master list of algorithms still to design payloads for, and | `0x01 0x01` | HLL | Classic ("Regular") | Section 3.1 | implemented | | `0x01 0x02` | HLL | Ertl-MLE ("Datafusion") | Section 3.1 | implemented | | `0x01 0x03` | HLL | HIP | Section 3.1 | implemented | -| `0x01 0x81` | HLL | Classic delta | Section 3.23 | designed | -| `0x01 0x82` | HLL | Ertl-MLE delta | Section 3.23 | designed | +| `0x01 0x81` | HLL | Classic delta | Section 3.23 | implemented | +| `0x01 0x82` | HLL | Ertl-MLE delta | Section 3.23 | implemented | | `0x02 0x00` | Count-Min | Count-Min | Section 3.2 | implemented | | `0x02 0x80` | Count-Min | Count-Min delta | Section 3.23 | implemented | | `0x03 0x00` | Count-Min-with-heap (CMSHeap) | Count-Min + top-k heap | Section 3.7 | implemented | diff --git a/docs/tests.md b/docs/tests.md index 647dcf62..31074813 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -106,6 +106,8 @@ Test file: [`src/sketches/hll.rs`](../src/sketches/hll.rs) Wire tests: [`src/sketches/hll/wire.rs`](../src/sketches/hll/wire.rs) +Delta tests: [`src/sketches/hll/delta.rs`](../src/sketches/hll/delta.rs) + | test_name | test_description | what_is_tested | | --- | --- | --- | | `hll_child_insert_emits_on_improvement` | Child insert emits delta only on register improvement. | Inserts a key via `insert_emit_delta` into `HyperLogLog`; verifies exactly `1` delta emitted on the first insert and `0` additional deltas on a duplicate insert. | @@ -129,6 +131,8 @@ Wire tests: [`src/sketches/hll/wire.rs`](../src/sketches/hll/wire.rs) | `hll_precision_cross_rejection` | The metadata `precision` pins the register storage the bytes belong to. | Verifies a populated `HyperLogLogP12` envelope fails to decode as the P14 `HyperLogLog`. | | `hll_hip_kind_id_rejected_by_classic` | The HIP kind_id is refused by the Classic decoder. | Verifies a populated `HyperLogLogHIP` envelope fails to decode as `HyperLogLog`. | | `classic_estimate_holds_past_the_32_bit_range` | The Classic estimator holds its closed form at ranks whose estimate passes the 32-bit range. | For ranks `12`, `16`, `18`, and `20`, drives one crafted hash per bucket through `HyperLogLog::::insert_with_hash` so all `16_384` P14 registers read that rank, and verifies `estimate()` is within a relative `1e-6` of `alpha_m * 16_384 * 2^rank` for `alpha_m = 0.7213 / (1 + 1.079 / 16_384)`. | +| `decode_rejects_each_payload_rule` | A `0x01 0x81` payload past the HLL bounds fails to decode. | For a P14 Classic delta, verifies `[[0, 16383], [1, 51]]` decodes and that index `16384` and register `52` each fail. | +| `decode_pins_the_precision` | A delta decodes only at its own precision. | Verifies a P14 Classic delta decodes and fails as a P12 delta. | ### KLL @@ -2172,7 +2176,9 @@ Test file: [`tests/asapv1_golden.rs`](../tests/asapv1_golden.rs) | `csheap_i64_regular_2x4_delta_matches_golden` | A known CSHeap delta serializes to its golden bytes and, merged, replaces the state's heap. | Verifies `CSHeapDelta::::new(2, 4, [0, 1, 2, 4, 6, 7], [-32, -128, 255, -129, 65535, -2147483649], heap)` with heap size `5` holding `i64` keys `{-1: 65536, 7: -33, -129: -4294967297}` serializes to `asapv1_golden/csheap_i64_regular_2x4_delta.hex`, that the fixture decodes and re-serializes identically, and that merged into the `string`-keyed `csheap_i64_regular_2x4_strkeys` it gives `[-32, -1, 383, 65536, -130, -33, 32767, -4294967297]` and exactly the delta's heap. | | `csheap_i64_regular_2x4_strkeys_matches_golden` | A CSHeap serializes to its golden bytes. | Verifies the `2x4` Count Sketch matrix with heap size `5` holding `{"alpha": 4294967296, "beta": 127, "delta": 127, "gamma": -33}` serializes to `asapv1_golden/csheap_i64_regular_2x4_strkeys.hex`, and that the decode holds that matrix and those entries and re-serializes identically. | | `hll_classic_p12_matches_golden` | A known P12 Classic register pattern serializes to its golden bytes on both the portable and native paths. | Builds `HllSketch::from_raw(HllVariant::Regular, 12, regs, 0.0, 0.0, 0.0)` where `regs` is 4,096 zeros with `[0] = 1`, `[1] = 7`, `[100] = 42` and `[4095] = 3`, verifies `to_msgpack()` equals `asapv1_golden/hll_classic_p12.hex`, that `HyperLogLogP12::::deserialize_from_bytes` on the fixture recovers those registers and re-serializes identically, and that `HllSketch::from_msgpack` reports the same registers, `precision` `12` and `HllVariant::Regular`. | +| `hll_classic_p14_delta_matches_golden` | A known P14 Classic delta serializes to its golden bytes and merges by `max`. | Verifies `HyperLogLogDelta::::new` over indices `[0, 2, 200, 8192, 16383]` and registers `[51, 1, 9, 7, 1]` serializes to `asapv1_golden/hll_classic_p14_delta.hex`, that the decoded fixture re-serializes identically, and that merged into `hll_classic_p14` it gives registers `[0] = 51`, `[1] = 7`, `[2] = 1`, `[200] = 9`, `[8192] = 42`, `[16383] = 51` and zero elsewhere. | | `hll_ertl_mle_p12_matches_golden` | The Datafusion variant tag reaches the bytes for the same P12 register pattern. | Builds `HllSketch::from_raw(HllVariant::Datafusion, 12, regs, 0.0, 0.0, 0.0)` over the shared P12 pattern (`[0] = 1`, `[1] = 7`, `[100] = 42`, `[4095] = 3` in 4,096 registers), verifies `to_msgpack()` equals `asapv1_golden/hll_ertl_mle_p12.hex`, that `HyperLogLogP12::::deserialize_from_bytes` recovers the registers and re-serializes identically, and that `HllSketch::from_msgpack` reports `HllVariant::Datafusion`. | +| `hll_ertl_mle_p14_delta_matches_golden` | The same delta under the Ertl-MLE kind_id. | Runs the `hll_classic_p14_delta_matches_golden` checks for `ErtlMLE` against `hll_ertl_mle_p14_delta` and `hll_ertl_mle_p14`. | | `hll_hip_p12_matches_golden` | The HIP variant's three running scalars travel on the wire beside its registers. | Builds `HllSketch::from_raw(HllVariant::Hip, 12, regs, 1.5, 2.5, 3.0)` over the shared P12 pattern, verifies `to_msgpack()` equals `asapv1_golden/hll_hip_p12.hex`, that `HyperLogLogHIPP12::deserialize_from_bytes` re-serializes the fixture identically, and that `HllSketch::from_msgpack` reports the registers, `HllVariant::Hip`, `hip_kxq0` `1.5`, `hip_kxq1` `2.5` and `hip_est` `3.0`. | | `kll_f64_k200_matches_golden` | A deterministic f64 KLL serializes to its golden bytes, which round-trip unchanged. | Verifies `KLL::::init_kll_with_seed(200, 42)` fed `1..=50` (below the level-0 capacity, so no compaction fires) serializes to `asapv1_golden/kll_f64_k200.hex`, and that decoding the fixture re-serializes to the same bytes and reports `quantile(0.0)` `1.0` and `quantile(1.0)` `50.0`. | | `kll_i64_k200_matches_golden` | A deterministic i64 KLL serializes to its golden bytes, which round-trip unchanged. | Verifies `KLL::::init_kll_with_seed(200, 42)` fed `1..=50` serializes to `asapv1_golden/kll_i64_k200.hex`, and that decoding the fixture re-serializes to the same bytes and reports `quantile(0.0)` `1.0` and `quantile(1.0)` `50.0`. | diff --git a/src/message_pack_format/envelope.rs b/src/message_pack_format/envelope.rs index 615fa54b..ca65c801 100644 --- a/src/message_pack_format/envelope.rs +++ b/src/message_pack_format/envelope.rs @@ -212,6 +212,9 @@ mod tests { let classic = hex(include_str!("../../asapv1_golden/hll_classic_p12.hex")); let hip = hex(include_str!("../../asapv1_golden/hll_hip_p12.hex")); + let classic_delta = hex(include_str!( + "../../asapv1_golden/hll_classic_p14_delta.hex" + )); let cms = hex(include_str!("../../asapv1_golden/cms_i64_regular_2x3.hex")); let cms_delta = hex(include_str!( "../../asapv1_golden/cms_i64_regular_2x3_delta.hex" @@ -297,6 +300,7 @@ mod tests { let cases: Vec = vec![ case!("HLL classic", classic.clone(), HllClassic), case!("HLL HIP", hip.clone(), HyperLogLogHIPP12), + case!("HLL classic delta", classic_delta, HyperLogLogDelta), ("HLL portable classic", classic, portable), ("HLL portable HIP", hip, portable), case!("CMS", cms, Cms), diff --git a/src/sketches/hll.rs b/src/sketches/hll.rs index 5464cd0a..3f3eb1fc 100644 --- a/src/sketches/hll.rs +++ b/src/sketches/hll.rs @@ -40,7 +40,9 @@ use crate::{CANONICAL_HASH_SEED, DataInput, DefaultXxHasher, SketchHasher, hash6 use serde::{Deserialize, Serialize}; use std::marker::PhantomData; +mod delta; mod wire; +pub use delta::HyperLogLogDelta; pub use wire::HllWireVariant; pub(crate) use wire::{ HLL_KIND_CLASSIC, HLL_KIND_ERTL_MLE, HLL_KIND_HIP, HllMetadata, HllPayloadHip, HllPayloadPlain, diff --git a/src/sketches/hll/delta.rs b/src/sketches/hll/delta.rs new file mode 100644 index 00000000..6ca8b2d9 --- /dev/null +++ b/src/sketches/hll/delta.rs @@ -0,0 +1,144 @@ +//! The HLL deltas, ASAPv1 kinds `0x01 0x81` (Classic) and `0x01 0x82` +//! (Ertl-MLE) (`docs/asapv1_wire_format.md` §3.23). Their metadata is the +//! state's (§3.1); their payload is `[indices, registers]`. + +use std::marker::PhantomData; + +use rmp_serde::{decode::Error as RmpDecodeError, encode::Error as RmpEncodeError}; +use serde::{Deserialize, Serialize}; + +use crate::message_pack_format::delta::check_cells; +use crate::message_pack_format::envelope; +use crate::structures::fixed_structure::HllRegisterStorage; +use crate::{DefaultXxHasher, HashProfile, SketchHasher}; + +use super::HyperLogLogImpl; +use super::wire::{HllWireVariant, check_register_range, encode_metadata, validated_hll_payload}; + +/// The delta kind_id of a variant: its state kind_id with the variant's top bit +/// set. +fn delta_kind() -> [u8; 2] { + [V::WIRE_KIND_ID[0], V::WIRE_KIND_ID[1] | 0x80] +} + +/// A change to a [`HyperLogLogImpl`] state: each value of `registers` is a new +/// value for the register its position names in `indices`. Indices are strictly +/// ascending and below `2^precision`; each value is non-zero and at most the +/// maximum rank, `65 - precision`. +#[derive(Clone, Debug)] +pub struct HyperLogLogDelta { + indices: Vec, + registers: Vec, + _config: PhantomData<(Variant, Registers, H)>, +} + +/// The delta payload: `[indices, registers]`, `registers` a msgpack `bin`. +#[derive(Serialize, Deserialize)] +struct HllDeltaPayload { + indices: Vec, + #[serde(with = "serde_bytes")] + registers: Vec, +} + +impl HyperLogLogDelta +where + Variant: HllWireVariant, + Registers: HllRegisterStorage, + H: SketchHasher + HashProfile, +{ + /// Builds a delta from the changed registers. Fails when they break the + /// rules above. + pub fn new(indices: Vec, registers: Vec) -> Result { + check_cells(&indices, ®isters, ..Registers::NUM_REGISTERS as u64) + .map_err(|e| format!("HLL delta: {e}"))?; + check_register_range::(®isters)?; + Ok(Self { + indices, + registers, + _config: PhantomData, + }) + } + + /// Serializes the delta into an ASAPv1 MessagePack envelope. + pub fn serialize_to_bytes(&self) -> Result, RmpEncodeError> { + let metadata = encode_metadata::()?; + let payload = rmp_serde::to_vec(&HllDeltaPayload { + indices: self.indices.clone(), + registers: self.registers.clone(), + })?; + Ok(envelope::encode( + &delta_kind::(), + &metadata, + &payload, + )) + } + + /// Deserializes a delta from an ASAPv1 MessagePack envelope. + pub fn deserialize_from_bytes(bytes: &[u8]) -> Result { + let payload = validated_hll_payload::( + bytes, + &delta_kind::(), + Registers::PRECISION as u32, + )?; + let p: HllDeltaPayload = envelope::decode_block(payload)?; + Self::new(p.indices, p.registers).map_err(RmpDecodeError::Uncategorized) + } +} + +impl + HyperLogLogImpl +{ + /// Raises each register the delta names to the delta's value, keeping the + /// larger of the two. + pub fn merge_delta(&mut self, delta: &HyperLogLogDelta) { + let registers = self.registers.as_mut_slice(); + for (&index, &value) in delta.indices.iter().zip(&delta.registers) { + let register = &mut registers[index as usize]; + *register = (*register).max(value); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sketches::hll::Classic; + use crate::structures::fixed_structure::{HllBucketListP12, HllBucketListP14}; + + type Delta = HyperLogLogDelta; + + /// A P14 Classic delta envelope around the given indices and registers. + fn delta_bytes(indices: &[u64], registers: &[u8]) -> Vec { + let metadata = encode_metadata::().unwrap(); + let payload = rmp_serde::to_vec(&HllDeltaPayload { + indices: indices.to_vec(), + registers: registers.to_vec(), + }) + .unwrap(); + envelope::encode(&delta_kind::(), &metadata, &payload) + } + + #[test] + fn decode_rejects_each_payload_rule() { + assert!(Delta::deserialize_from_bytes(&delta_bytes(&[0, 16383], &[1, 51])).is_ok()); + let rows: [(&str, &[u64], &[u8]); 2] = [ + ("index 2^precision", &[16384], &[1]), + ("register past the maximum rank", &[0], &[52]), + ]; + for (label, indices, registers) in rows { + assert!( + Delta::deserialize_from_bytes(&delta_bytes(indices, registers)).is_err(), + "{label}" + ); + } + } + + #[test] + fn decode_pins_the_precision() { + let delta = delta_bytes(&[0], &[1]); + assert!(Delta::deserialize_from_bytes(&delta).is_ok()); + assert!( + HyperLogLogDelta::::deserialize_from_bytes(&delta).is_err() + ); + } +} diff --git a/src/sketches/hll/wire.rs b/src/sketches/hll/wire.rs index 17eca577..de6b044c 100644 --- a/src/sketches/hll/wire.rs +++ b/src/sketches/hll/wire.rs @@ -1,12 +1,13 @@ //! ASAPv1 wire serialization for the HyperLogLog sketches. //! -//! Child submodule of [`crate::sketches::hll`]: it holds ALL of HLL's +//! Child submodule of [`crate::sketches::hll`]: it holds HLL's state //! serialization (the metadata/payload DTOs, kind_id constants, the //! [`HllWireVariant`] mapping, and the `serialize_to_bytes` / //! `deserialize_from_bytes` impls) while the algorithm lives in the parent -//! module file (`hll.rs`). Being a descendant module, it can read the sketch structs' private -//! fields (`self.registers`) and construct them directly without widening any -//! field visibility. See `docs/asapv1_wire_format.md`. +//! module file (`hll.rs`) and the delta kinds in `delta.rs`. Being a +//! descendant module, it can read the sketch structs' private fields +//! (`self.registers`) and construct them directly without widening any field +//! visibility. See `docs/asapv1_wire_format.md`. use rmp_serde::{decode::Error as RmpDecodeError, encode::Error as RmpEncodeError}; use serde::{Deserialize, Serialize}; @@ -101,10 +102,16 @@ pub(crate) fn standard_hll_metadata(precision: u32) -> HllMetadata { hll_metadata::(precision) } +/// The metadata block of a sketch with `Registers` storage hashed under `H`. +pub(super) fn encode_metadata() +-> Result, RmpEncodeError> { + rmp_serde::to_vec_named(&hll_metadata::(Registers::PRECISION as u32)) +} + /// Validate the envelope for a known target and return the raw payload bytes. /// The metadata is checked against the profile of `H`, so bytes hashed under a /// different profile are rejected (fail closed). -fn validated_hll_payload<'a, H: HashProfile>( +pub(super) fn validated_hll_payload<'a, H: HashProfile>( bytes: &'a [u8], expected_kind_id: &[u8], expected_precision: u32, @@ -129,7 +136,9 @@ fn validated_hll_payload<'a, H: HashProfile>( /// register holds a rank of at most `REGISTER_BITS + 1`, so a byte above that /// bound is an impossible state. Both directions call this, so the encode and /// decode predicates cannot drift. -fn check_register_range(registers: &[u8]) -> Result<(), String> { +pub(super) fn check_register_range( + registers: &[u8], +) -> Result<(), String> { let max_rank = (Registers::REGISTER_BITS + 1) as u8; match registers.iter().find(|&&rank| rank > max_rank) { Some(&rank) => Err(format!( @@ -187,7 +196,7 @@ impl H: HashProfile, { let registers = registers_to_bytes(&self.registers)?; - let metadata = rmp_serde::to_vec_named(&hll_metadata::(Registers::PRECISION as u32))?; + let metadata = encode_metadata::()?; let payload = rmp_serde::to_vec(&HllPayloadPlain { registers })?; Ok(envelope::encode(Variant::WIRE_KIND_ID, &metadata, &payload)) } @@ -219,8 +228,7 @@ impl HyperLogLogHIPImpl { /// than bytes that would be refused on decode. pub fn serialize_to_bytes(&self) -> Result, RmpEncodeError> { let registers = registers_to_bytes(&self.registers)?; - let metadata = - rmp_serde::to_vec_named(&standard_hll_metadata(Registers::PRECISION as u32))?; + let metadata = encode_metadata::()?; let payload = rmp_serde::to_vec(&HllPayloadHip { registers, hip_kxq0: self.kxq0, diff --git a/src/sketches/mod.rs b/src/sketches/mod.rs index 52d19880..780864fe 100644 --- a/src/sketches/mod.rs +++ b/src/sketches/mod.rs @@ -83,8 +83,8 @@ pub use elastic::HeavyBucket; /// HyperLogLog implementations and aliases. pub mod hll; pub use hll::{ - Classic, ErtlMLE, HyperLogLog, HyperLogLogHIP, HyperLogLogHIPP12, HyperLogLogHIPP14, - HyperLogLogHIPP16, HyperLogLogP12, HyperLogLogP14, HyperLogLogP16, + Classic, ErtlMLE, HyperLogLog, HyperLogLogDelta, HyperLogLogHIP, HyperLogLogHIPP12, + HyperLogLogHIPP14, HyperLogLogHIPP16, HyperLogLogP12, HyperLogLogP14, HyperLogLogP16, }; pub mod kll; diff --git a/tests/asapv1_golden.rs b/tests/asapv1_golden.rs index ed2e712b..a57f307a 100644 --- a/tests/asapv1_golden.rs +++ b/tests/asapv1_golden.rs @@ -9,10 +9,12 @@ //! See `asapv1_golden/README.md`. use asap_sketchlib::sketches::CountSketchDelta; +use asap_sketchlib::sketches::hll::HllWireVariant; use asap_sketchlib::{ CMSHeap, CMSHeapDelta, CSHeap, CSHeapDelta, Classic, Count, CountMin, CountMinDelta, ErtlMLE, - FastPath, HHHeap, HeapCount, HeapItem, HllSketch, HllVariant, HyperLogLogHIPP12, - HyperLogLogP12, KLL, MessagePackCodec, RegularPath, Vector2D, peek_kind_id, + FastPath, HHHeap, HeapCount, HeapItem, HllBucketListP14, HllSketch, HllVariant, + HyperLogLogDelta, HyperLogLogHIPP12, HyperLogLogP12, HyperLogLogP14, KLL, MessagePackCodec, + RegularPath, Vector2D, peek_kind_id, }; fn decode_hex(s: &str) -> Vec { @@ -27,6 +29,10 @@ fn decode_hex(s: &str) -> Vec { const GOLDEN_CLASSIC: &str = include_str!("../asapv1_golden/hll_classic_p12.hex"); const GOLDEN_ERTL: &str = include_str!("../asapv1_golden/hll_ertl_mle_p12.hex"); const GOLDEN_HIP: &str = include_str!("../asapv1_golden/hll_hip_p12.hex"); +const GOLDEN_CLASSIC_P14: &str = include_str!("../asapv1_golden/hll_classic_p14.hex"); +const GOLDEN_ERTL_P14: &str = include_str!("../asapv1_golden/hll_ertl_mle_p14.hex"); +const GOLDEN_CLASSIC_P14_DELTA: &str = include_str!("../asapv1_golden/hll_classic_p14_delta.hex"); +const GOLDEN_ERTL_P14_DELTA: &str = include_str!("../asapv1_golden/hll_ertl_mle_p14_delta.hex"); const GOLDEN_CMS_I64: &str = include_str!("../asapv1_golden/cms_i64_regular_2x3.hex"); const GOLDEN_CMS_F64: &str = include_str!("../asapv1_golden/cms_f64_fast_2x3.hex"); const GOLDEN_CMS_I64_DELTA: &str = include_str!("../asapv1_golden/cms_i64_regular_2x3_delta.hex"); @@ -145,6 +151,42 @@ fn hll_hip_p12_matches_golden() { assert_eq!(decoded.hip_est, 3.0); } +// --------------------------------------------------------------------------- +// HLL delta: build known delta -> serialize == golden, round-trips, and merged +// into the matching P14 state golden gives the expected registers. +// --------------------------------------------------------------------------- + +fn assert_hll_p14_delta(delta_hex: &str, state_hex: &str) { + type Delta = HyperLogLogDelta; + let want = decode_hex(delta_hex); + let indices = vec![0, 2, 200, 8192, 16383]; + let registers = vec![51, 1, 9, 7, 1]; + let delta = Delta::::new(indices, registers).expect("valid"); + assert_eq!(delta.serialize_to_bytes().expect("serialize"), want); + + let decoded = Delta::::deserialize_from_bytes(&want).expect("decode"); + assert_eq!(decoded.serialize_to_bytes().expect("re-serialize"), want); + + let mut state = + HyperLogLogP14::::deserialize_from_bytes(&decode_hex(state_hex)).expect("state"); + state.merge_delta(&decoded); + let mut expected = vec![0u8; 16384]; + for (index, value) in [(0, 51), (1, 7), (2, 1), (200, 9), (8192, 42), (16383, 51)] { + expected[index] = value; + } + assert_eq!(state.registers_as_slice(), expected.as_slice()); +} + +#[test] +fn hll_classic_p14_delta_matches_golden() { + assert_hll_p14_delta::(GOLDEN_CLASSIC_P14_DELTA, GOLDEN_CLASSIC_P14); +} + +#[test] +fn hll_ertl_mle_p14_delta_matches_golden() { + assert_hll_p14_delta::(GOLDEN_ERTL_P14_DELTA, GOLDEN_ERTL_P14); +} + // --------------------------------------------------------------------------- // Count-Min: build known matrix state -> serialize == golden, round-trips. // --------------------------------------------------------------------------- From 1f34364552725e81428873e9e62a5fc9b8be8c38 Mon Sep 17 00:00:00 2001 From: GordonYuanyc Date: Fri, 16 Oct 2026 05:19:36 +0000 Subject: [PATCH 6/7] feat(setagg): ASAPv1 codecs for SetAggregator and its delta SetAggregator encodes as 08 00 and DeltaResult as 08 80: metadata {metadata_version: 1}, members as strictly ascending str arrays, added and removed disjoint. Goldens bumped to the 08 80 DeltaResult fixtures. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + docs/asapv1_wire_format.md | 6 +- docs/message_pack_format.md | 12 +- docs/tests.md | 12 + src/message_pack_format/envelope.rs | 6 + .../portable/delta_set_aggregator.rs | 3 +- .../portable/set_aggregator.rs | 7 +- .../portable/set_aggregator/wire.rs | 259 ++++++++++++++++++ tests/asapv1_golden.rs | 88 +++++- 9 files changed, 380 insertions(+), 15 deletions(-) create mode 100644 src/message_pack_format/portable/set_aggregator/wire.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4117962d..90c31d69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,6 +83,8 @@ signals a backwards-compatible change. (`0x01 0x81`, `0x01 0x82`): built with `new` from changed registers, serialized like a sketch, and merged register-wise by `max` with `HyperLogLogImpl::merge_delta`. +- **ASAPv1 for `SetAggregator` (`0x08 0x00`) and `DeltaResult` (`0x08 0x80`)**: + `serialize_to_bytes` / `deserialize_from_bytes` on both types. ### Changed diff --git a/docs/asapv1_wire_format.md b/docs/asapv1_wire_format.md index 92a44e78..e84708b3 100644 --- a/docs/asapv1_wire_format.md +++ b/docs/asapv1_wire_format.md @@ -26,7 +26,7 @@ If the doc feels long, these sections carry the key points: ## Status -- **Implemented (Rust).** Every kind_id the Section 1 registry marks *implemented* serializes through the shared `message_pack_format::envelope` module per this spec: HLL (three estimators) with the Classic and Ertl-MLE deltas (`HyperLogLogDelta`), Count-Min with its delta (`CountMinDelta`), CMSHeap with its delta (`CMSHeapDelta`), Count Sketch with its delta (`sketches::CountSketchDelta`), CSHeap with its delta (`CSHeapDelta`), DDSketch, KLL (compact + dynamic), Hydra (five counter variants), Elastic, Coco, UniformSampling, KMV, UnivMon, UnivMon Optimized, ExponentialHistogram, EHSketchList, Bloom, Space-Saving, CountL2HH and UnivMon-Q. +- **Implemented (Rust).** Every kind_id the Section 1 registry marks *implemented* serializes through the shared `message_pack_format::envelope` module per this spec: HLL (three estimators) with the Classic and Ertl-MLE deltas (`HyperLogLogDelta`), Count-Min with its delta (`CountMinDelta`), CMSHeap with its delta (`CMSHeapDelta`), Count Sketch with its delta (`sketches::CountSketchDelta`), CSHeap with its delta (`CSHeapDelta`), DDSketch, KLL (compact + dynamic), Hydra (five counter variants), Elastic, Coco, UniformSampling, KMV, UnivMon, UnivMon Optimized, ExponentialHistogram, EHSketchList, Bloom, Space-Saving, CountL2HH, UnivMon-Q, and SetAggregator with its delta (`DeltaResult`). - **Self-describing.** The hash-spec metadata is derived from the hasher's `HashProfile` (read live, never hardcoded), so the bytes truthfully describe how a sketch was hashed; custom hash profiles are supported (Section 2). - **Byte-level encoding** is pinned in Section 4; the resolved decisions are summarized at the end. - **`sketchlib-go`** is aligned separately (see Cross-language contract). @@ -203,8 +203,8 @@ This registry is the master list of algorithms still to design payloads for, and | `0x07 0x02` | Hydra | Count Sketch counter | Section 3.9 | implemented | | `0x07 0x03` | Hydra | HyperLogLog counter | Section 3.9 | implemented | | `0x07 0x04` | Hydra | UnivMon counter | Section 3.9 | implemented | -| `0x08 0x00` | SetAggregator | Exact string set | Section 3.21 | designed | -| `0x08 0x80` | SetAggregator | SetAggregator delta | Section 3.22 | designed | +| `0x08 0x00` | SetAggregator | Exact string set | Section 3.21 | implemented | +| `0x08 0x80` | SetAggregator | SetAggregator delta | Section 3.22 | implemented | | `0x09 0x00` | - | - | TBD | retired / permanently reserved, never reassign | | `0x0a 0x00` | Count-Sketch-with-heap (CSHeap) | Count Sketch + top-k heap | Section 3.10 | implemented | | `0x0a 0x80` | Count-Sketch-with-heap (CSHeap) | CSHeap delta | Section 3.23 | implemented | diff --git a/docs/message_pack_format.md b/docs/message_pack_format.md index 12a2b91c..b934526f 100644 --- a/docs/message_pack_format.md +++ b/docs/message_pack_format.md @@ -138,6 +138,10 @@ Converted: - **ExponentialHistogram** (`src/sketch_framework/eh/wire.rs`, `0x13 0x00`) and **EHSketchList** (`src/sketch_framework/eh_sketch_list/wire.rs`, `0x14 0x00`) — one `[kind_id, descriptor, state]` triple, standalone and per bucket. +- **SetAggregator** and **DeltaResult** + (`src/message_pack_format/portable/set_aggregator/wire.rs`, `0x08 0x00` and + `0x08 0x80`) — the members, and the added and removed members, as strictly + ascending `str` arrays. NitroBatch, FoldCMS, FoldCS, HashSketchEnsemble, EHUnivOptimized and OctoSketch have **no `wire.rs`**; their `kind_id`s are reserved with payload TBD. @@ -169,10 +173,10 @@ decode → re-encode them byte-identically. The `kind_id` registry is mirrored verbatim with Go's `wire/asapmsgpack/magic_ids.go`, never independently allocated. -That proof covers the six `kind_id`s a fixture exists for — HLL's three -estimators, Count-Min, Count Sketch and compact KLL. Every other implemented -kind has **no ASAPv1 golden and therefore no ASAPv1 drift guard**; -[its README](https://github.com/ProjectASAP/sketchlib-golden-bytes#coverage) lists what is covered. +That proof covers the eight `kind_id`s the test checks — HLL's three +estimators, Count-Min, Count Sketch, compact KLL, SetAggregator and its delta. +Every other implemented kind has **no ASAPv1 golden and therefore no ASAPv1 +drift guard**; [its README](https://github.com/ProjectASAP/sketchlib-golden-bytes#coverage) lists what is covered. The older `portable` path is guarded separately, by the `sketchlib-go` goldens of its own listed above. diff --git a/docs/tests.md b/docs/tests.md index 31074813..f1428407 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -1448,6 +1448,8 @@ Test file: [`src/message_pack_format/portable/sampling.rs`](../src/message_pack_ Test file: [`src/message_pack_format/portable/set_aggregator.rs`](../src/message_pack_format/portable/set_aggregator.rs) +Wire tests: [`src/message_pack_format/portable/set_aggregator/wire.rs`](../src/message_pack_format/portable/set_aggregator/wire.rs) + | test_name | test_description | what_is_tested | | --- | --- | --- | | `test_creation` | A fresh aggregator holds nothing. | Verifies `SetAggregator::new()` has an empty `values` set. | @@ -1455,6 +1457,12 @@ Test file: [`src/message_pack_format/portable/set_aggregator.rs`](../src/message | `test_merge` | Merge is set union. | Merges an aggregator holding `{"api", "db"}` into one holding `{"web", "api"}` and verifies the result holds exactly `"web"`, `"api"`, and `"db"`. | | `test_msgpack_round_trip` | The set survives the round trip. | Round-trips an aggregator holding `{"web", "api"}` and verifies the decode holds 2 keys, both names present. | | `test_msgpack_matches_wire_format` | The bytes are a named map under a `values` key. | For an aggregator holding `{"a"}`, verifies the bytes decode into a locally declared `StringSet { values: HashSet }` that still contains `"a"`. | +| `set_aggregator_rejects_unordered_and_duplicate_members` | `values` must be strictly ascending by UTF-8 bytes. | Verifies crafted payloads `["b", "a"]` and `["a", "a"]` each fail to decode. | +| `a_bin_member_is_rejected` | A member must be msgpack `str`. | Verifies a set payload whose one member is the `bin` `b"a"`, and a delta whose `removed` holds that `bin`, each fail to decode. | +| `metadata_must_be_exactly_metadata_version_1` | The metadata map is exactly `{metadata_version: 1}`. | Verifies a map with an extra `bogus_field` key, and one with `metadata_version` `2`, each fail to decode. | +| `each_decoder_rejects_a_foreign_kind_id` | Each decoder accepts only its own kind_id. | Verifies a well-formed set payload framed under `0x08 0x80` fails as a `SetAggregator`, and a well-formed delta payload under `0x08 0x00` or the retired `0x09 0x00` fails as a `DeltaResult`. | +| `delta_result_rejects_a_member_both_added_and_removed` | `added` and `removed` are disjoint on both sides of the wire. | Verifies a crafted delta with `added` `["a", "b"]` and `removed` `["b"]` fails to decode, and that a `DeltaResult` holding `"a"` in both sets fails to serialize. | +| `delta_result_rejects_unordered_sides` | Each side is strictly ascending. | Verifies a delta with `added` `["b", "a"]`, and one with `removed` `["c", "c"]`, each fail to decode. | ## Common @@ -2175,6 +2183,8 @@ Test file: [`tests/asapv1_golden.rs`](../tests/asapv1_golden.rs) | `csheap_f64_fast_2x4_strkeys_matches_golden` | `f64` heap counts are the unrounded signed median, a tie broken by the key. | Verifies an `f64` fast-path `CSHeap` over the `2x4` f64 Count Sketch matrix with heap size `4` holding `{"alpha": 63.5625, "aa": -0.75, "beta": -0.75, "gamma": -16384.25}` serializes to `asapv1_golden/csheap_f64_fast_2x4_strkeys.hex`, and that the decode holds that matrix and those entries and re-serializes identically. | | `csheap_i64_regular_2x4_delta_matches_golden` | A known CSHeap delta serializes to its golden bytes and, merged, replaces the state's heap. | Verifies `CSHeapDelta::::new(2, 4, [0, 1, 2, 4, 6, 7], [-32, -128, 255, -129, 65535, -2147483649], heap)` with heap size `5` holding `i64` keys `{-1: 65536, 7: -33, -129: -4294967297}` serializes to `asapv1_golden/csheap_i64_regular_2x4_delta.hex`, that the fixture decodes and re-serializes identically, and that merged into the `string`-keyed `csheap_i64_regular_2x4_strkeys` it gives `[-32, -1, 383, 65536, -130, -33, 32767, -4294967297]` and exactly the delta's heap. | | `csheap_i64_regular_2x4_strkeys_matches_golden` | A CSHeap serializes to its golden bytes. | Verifies the `2x4` Count Sketch matrix with heap size `5` holding `{"alpha": 4294967296, "beta": 127, "delta": 127, "gamma": -33}` serializes to `asapv1_golden/csheap_i64_regular_2x4_strkeys.hex`, and that the decode holds that matrix and those entries and re-serializes identically. | +| `delta_result_empty_matches_golden` | An empty delta serializes to its golden bytes, which round-trip unchanged. | Verifies a `DeltaResult` with both sets empty serializes to `asapv1_golden/delta_result_empty.hex` (kind_id `0x08 0x80`), and that decoding the fixture yields two empty sets and re-serializes to the same bytes. | +| `delta_result_strings_matches_golden` | A delta's two sides serialize in ascending UTF-8 byte order to the golden bytes. | Verifies a `DeltaResult` with `added` `{"😀", "queue", "été"}` and `removed` `{"中", "db", "", "cache"}` serializes to `asapv1_golden/delta_result_strings.hex`, and that decoding the fixture recovers both sets and re-serializes to the same bytes. | | `hll_classic_p12_matches_golden` | A known P12 Classic register pattern serializes to its golden bytes on both the portable and native paths. | Builds `HllSketch::from_raw(HllVariant::Regular, 12, regs, 0.0, 0.0, 0.0)` where `regs` is 4,096 zeros with `[0] = 1`, `[1] = 7`, `[100] = 42` and `[4095] = 3`, verifies `to_msgpack()` equals `asapv1_golden/hll_classic_p12.hex`, that `HyperLogLogP12::::deserialize_from_bytes` on the fixture recovers those registers and re-serializes identically, and that `HllSketch::from_msgpack` reports the same registers, `precision` `12` and `HllVariant::Regular`. | | `hll_classic_p14_delta_matches_golden` | A known P14 Classic delta serializes to its golden bytes and merges by `max`. | Verifies `HyperLogLogDelta::::new` over indices `[0, 2, 200, 8192, 16383]` and registers `[51, 1, 9, 7, 1]` serializes to `asapv1_golden/hll_classic_p14_delta.hex`, that the decoded fixture re-serializes identically, and that merged into `hll_classic_p14` it gives registers `[0] = 51`, `[1] = 7`, `[2] = 1`, `[200] = 9`, `[8192] = 42`, `[16383] = 51` and zero elsewhere. | | `hll_ertl_mle_p12_matches_golden` | The Datafusion variant tag reaches the bytes for the same P12 register pattern. | Builds `HllSketch::from_raw(HllVariant::Datafusion, 12, regs, 0.0, 0.0, 0.0)` over the shared P12 pattern (`[0] = 1`, `[1] = 7`, `[100] = 42`, `[4095] = 3` in 4,096 registers), verifies `to_msgpack()` equals `asapv1_golden/hll_ertl_mle_p12.hex`, that `HyperLogLogP12::::deserialize_from_bytes` recovers the registers and re-serializes identically, and that `HllSketch::from_msgpack` reports `HllVariant::Datafusion`. | @@ -2182,6 +2192,8 @@ Test file: [`tests/asapv1_golden.rs`](../tests/asapv1_golden.rs) | `hll_hip_p12_matches_golden` | The HIP variant's three running scalars travel on the wire beside its registers. | Builds `HllSketch::from_raw(HllVariant::Hip, 12, regs, 1.5, 2.5, 3.0)` over the shared P12 pattern, verifies `to_msgpack()` equals `asapv1_golden/hll_hip_p12.hex`, that `HyperLogLogHIPP12::deserialize_from_bytes` re-serializes the fixture identically, and that `HllSketch::from_msgpack` reports the registers, `HllVariant::Hip`, `hip_kxq0` `1.5`, `hip_kxq1` `2.5` and `hip_est` `3.0`. | | `kll_f64_k200_matches_golden` | A deterministic f64 KLL serializes to its golden bytes, which round-trip unchanged. | Verifies `KLL::::init_kll_with_seed(200, 42)` fed `1..=50` (below the level-0 capacity, so no compaction fires) serializes to `asapv1_golden/kll_f64_k200.hex`, and that decoding the fixture re-serializes to the same bytes and reports `quantile(0.0)` `1.0` and `quantile(1.0)` `50.0`. | | `kll_i64_k200_matches_golden` | A deterministic i64 KLL serializes to its golden bytes, which round-trip unchanged. | Verifies `KLL::::init_kll_with_seed(200, 42)` fed `1..=50` serializes to `asapv1_golden/kll_i64_k200.hex`, and that decoding the fixture re-serializes to the same bytes and reports `quantile(0.0)` `1.0` and `quantile(1.0)` `50.0`. | +| `set_aggregator_empty_matches_golden` | An empty set serializes to its golden bytes, which round-trip unchanged. | Verifies an empty `SetAggregator` serializes to `asapv1_golden/set_aggregator_empty.hex`, and that decoding the fixture yields an empty set and re-serializes to the same bytes. | +| `set_aggregator_strings_matches_golden` | A set's members serialize in ascending UTF-8 byte order to the golden bytes. | Verifies a `SetAggregator` of 11 members, among them `""`, a 31-byte fixstr, a 32-byte str8, a proper-prefix chain and two-, three- and four-byte code points, serializes to `asapv1_golden/set_aggregator_strings.hex`, and that decoding the fixture recovers the set and re-serializes to the same bytes. | ### MessagePack Envelope Compatibility diff --git a/src/message_pack_format/envelope.rs b/src/message_pack_format/envelope.rs index ca65c801..2b530151 100644 --- a/src/message_pack_format/envelope.rs +++ b/src/message_pack_format/envelope.rs @@ -227,6 +227,10 @@ mod tests { let cms_heap_delta = hex(include_str!( "../../asapv1_golden/cmsheap_i64_regular_2x3_delta.hex" )); + let set = hex(include_str!( + "../../asapv1_golden/set_aggregator_strings.hex" + )); + let delta = hex(include_str!("../../asapv1_golden/delta_result_strings.hex")); let str_keys = ["alpha", "beta", "gamma", "alpha", "alpha"].map(DataInput::Str); let bytes_keys = [b"ab", b"cd", b"ab"].map(|key| DataInput::Bytes(key)); @@ -308,6 +312,8 @@ mod tests { case!("CS", cs, Cs), case!("CS delta", cs_delta, sketches::CountSketchDelta), case!("KLL", kll, KLL), + case!("SetAggregator", set, SetAggregator), + case!("DeltaResult", delta, DeltaResult), sketch!("KLL dynamic", kll_dynamic, KLLDynamic), sketch!("CMSHeap", cms_heap, CmsHeap), sketch!("CMSHeap bytes", cms_heap_bytes, CmsHeap), diff --git a/src/message_pack_format/portable/delta_set_aggregator.rs b/src/message_pack_format/portable/delta_set_aggregator.rs index d26dbeb9..1aaef0f2 100644 --- a/src/message_pack_format/portable/delta_set_aggregator.rs +++ b/src/message_pack_format/portable/delta_set_aggregator.rs @@ -1,6 +1,7 @@ //! Wire-format DTO for the delta set aggregator. //! -//! Owns the [`DeltaResult`] wire DTO and its [`MessagePackCodec`] impl. +//! Owns the [`DeltaResult`] wire DTO and its [`MessagePackCodec`] impl; its +//! ASAPv1 `serialize_to_bytes` (§3.22) is beside `SetAggregator`'s. //! The streaming/state-tracking logic lives in the downstream consumer //! (ASAPQuery's accumulators) — only the over-the-wire shape lives here. diff --git a/src/message_pack_format/portable/set_aggregator.rs b/src/message_pack_format/portable/set_aggregator.rs index d346a9e8..b3e17fae 100644 --- a/src/message_pack_format/portable/set_aggregator.rs +++ b/src/message_pack_format/portable/set_aggregator.rs @@ -1,9 +1,8 @@ //! Set aggregator: wire-format type for tracking a set of unique string keys. //! //! `SetAggregator` is not a sketch — it's a plain `HashSet` wrapped -//! for cross-language interop. Wire format: `StringSet { values: -//! HashSet }` in MessagePack. No `sketches::*` equivalent exists, -//! so both the wire shape and the runtime API live here. +//! for cross-language interop. `serialize_to_bytes` writes ASAPv1 (§3.21); +//! [`MessagePackCodec`] writes `StringSet { values: HashSet }`. use std::collections::HashSet; @@ -11,6 +10,8 @@ use serde::{Deserialize, Serialize}; use crate::message_pack_format::{Error as MsgPackError, MessagePackCodec}; +mod wire; + /// Set aggregator for tracking a set of unique string keys. #[derive(Debug, Clone)] pub struct SetAggregator { diff --git a/src/message_pack_format/portable/set_aggregator/wire.rs b/src/message_pack_format/portable/set_aggregator/wire.rs new file mode 100644 index 00000000..2b47e6bf --- /dev/null +++ b/src/message_pack_format/portable/set_aggregator/wire.rs @@ -0,0 +1,259 @@ +//! ASAPv1 wire serialization for [`SetAggregator`] (`0x08 0x00`, §3.21) and +//! its delta [`DeltaResult`] (`0x08 0x80`, §3.22). +//! +//! Neither type hashes or takes a construction parameter, so the metadata map +//! is exactly `{metadata_version: 1}`. Every member array is strictly ascending +//! by UTF-8 bytes, so a set has one encoding; members are msgpack `str` only. + +use std::collections::HashSet; + +use rmp_serde::{decode::Error as RmpDecodeError, encode::Error as RmpEncodeError}; +use serde::{Deserialize, Serialize}; + +use crate::message_pack_format::envelope; +use crate::message_pack_format::portable::delta_set_aggregator::DeltaResult; +use crate::message_pack_format::wire_key::WireString; + +use super::SetAggregator; + +/// SetAggregator kind_id: family `0x08`, variant `0x00`. +const SET_AGGREGATOR_KIND: &[u8] = &[0x08, 0x00]; + +/// SetAggregator delta kind_id: the delta bit set on variant `0x00`. +const SET_AGGREGATOR_DELTA_KIND: &[u8] = &[0x08, 0x80]; + +/// The metadata map both kinds carry, a msgpack **map** (`to_vec_named`). +#[derive(Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct SetMetadata { + metadata_version: u8, +} + +const SET_METADATA: SetMetadata = SetMetadata { + metadata_version: 1, +}; + +/// SetAggregator payload (§3.21), positional: `[values]`. +#[derive(Debug, Serialize, Deserialize)] +struct SetPayload { + values: Vec, +} + +/// DeltaResult payload (§3.22), positional: `[added, removed]`. +#[derive(Debug, Serialize, Deserialize)] +struct DeltaPayload { + added: Vec, + removed: Vec, +} + +/// The set's members in ascending UTF-8 byte order. +fn ascending(set: &HashSet) -> Vec<&str> { + let mut members: Vec<&str> = set.iter().map(String::as_str).collect(); + members.sort_unstable(); + members +} + +/// Rebuilds a set from a member array, rejecting one that is not strictly +/// ascending by UTF-8 bytes. +fn read_ascending( + field: &str, + members: Vec, +) -> Result, RmpDecodeError> { + if let Some(idx) = members.windows(2).position(|pair| pair[0].0 >= pair[1].0) { + return Err(RmpDecodeError::Uncategorized(format!( + "ASAPv1 {field} must be strictly ascending by UTF-8 bytes, broken at index {}", + idx + 1 + ))); + } + Ok(members.into_iter().map(WireString::into_string).collect()) +} + +/// Rejects a member that is in both `added` and `removed`. +fn check_disjoint(added: &HashSet, removed: &HashSet) -> Result<(), String> { + match added.intersection(removed).next() { + Some(member) => Err(format!( + "ASAPv1 DeltaResult: member {member:?} is both added and removed" + )), + None => Ok(()), + } +} + +/// Splits the envelope, checks the kind_id and the metadata, and returns the +/// payload block. +fn open<'a>(bytes: &'a [u8], kind: &[u8], name: &str) -> Result<&'a [u8], RmpDecodeError> { + let (kind_id, metadata, payload) = + envelope::split(bytes).map_err(RmpDecodeError::Uncategorized)?; + if kind_id != kind { + return Err(RmpDecodeError::Uncategorized(format!( + "{name} kind_id mismatch: stored {kind_id:?}, expected {kind:?}" + ))); + } + let meta: SetMetadata = envelope::decode_block(metadata)?; + if meta != SET_METADATA { + return Err(RmpDecodeError::Uncategorized(format!( + "ASAPv1 {name} envelope: metadata mismatch" + ))); + } + Ok(payload) +} + +impl SetAggregator { + /// Serializes the set into an ASAPv1 MessagePack envelope, members in + /// ascending UTF-8 byte order. + pub fn serialize_to_bytes(&self) -> Result, RmpEncodeError> { + let metadata = rmp_serde::to_vec_named(&SET_METADATA)?; + let payload = rmp_serde::to_vec(&SetPayload { + values: ascending(&self.values), + })?; + Ok(envelope::encode(SET_AGGREGATOR_KIND, &metadata, &payload)) + } + + /// Deserializes a set from an ASAPv1 MessagePack envelope. + pub fn deserialize_from_bytes(bytes: &[u8]) -> Result { + let payload = open(bytes, SET_AGGREGATOR_KIND, "SetAggregator")?; + let p: SetPayload = envelope::decode_block(payload)?; + Ok(SetAggregator { + values: read_ascending("SetAggregator values", p.values)?, + }) + } +} + +impl DeltaResult { + /// Serializes the delta into an ASAPv1 MessagePack envelope under the + /// SetAggregator delta kind_id, each side in ascending UTF-8 byte order. + /// A member in both `added` and `removed` is an error. + pub fn serialize_to_bytes(&self) -> Result, RmpEncodeError> { + check_disjoint(&self.added, &self.removed).map_err(RmpEncodeError::Syntax)?; + let metadata = rmp_serde::to_vec_named(&SET_METADATA)?; + let payload = rmp_serde::to_vec(&DeltaPayload { + added: ascending(&self.added), + removed: ascending(&self.removed), + })?; + Ok(envelope::encode( + SET_AGGREGATOR_DELTA_KIND, + &metadata, + &payload, + )) + } + + /// Deserializes a delta from an ASAPv1 MessagePack envelope. + pub fn deserialize_from_bytes(bytes: &[u8]) -> Result { + let payload = open(bytes, SET_AGGREGATOR_DELTA_KIND, "DeltaResult")?; + let p: DeltaPayload = envelope::decode_block(payload)?; + let added = read_ascending("DeltaResult added", p.added)?; + let removed = read_ascending("DeltaResult removed", p.removed)?; + check_disjoint(&added, &removed).map_err(RmpDecodeError::Uncategorized)?; + Ok(DeltaResult { added, removed }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn set_of(members: &[&str]) -> HashSet { + members.iter().map(|m| m.to_string()).collect() + } + + fn metadata_bytes() -> Vec { + rmp_serde::to_vec_named(&SET_METADATA).unwrap() + } + + fn crafted_set(values: &[&str]) -> Vec { + let payload = rmp_serde::to_vec(&SetPayload { + values: values.to_vec(), + }) + .unwrap(); + envelope::encode(SET_AGGREGATOR_KIND, &metadata_bytes(), &payload) + } + + fn crafted_delta(added: &[&str], removed: &[&str]) -> Vec { + let payload = rmp_serde::to_vec(&DeltaPayload { + added: added.to_vec(), + removed: removed.to_vec(), + }) + .unwrap(); + envelope::encode(SET_AGGREGATOR_DELTA_KIND, &metadata_bytes(), &payload) + } + + #[test] + fn set_aggregator_rejects_unordered_and_duplicate_members() { + assert!(SetAggregator::deserialize_from_bytes(&crafted_set(&["b", "a"])).is_err()); + assert!(SetAggregator::deserialize_from_bytes(&crafted_set(&["a", "a"])).is_err()); + } + + /// A `bin` member is rejected even when it holds valid UTF-8. + #[test] + fn a_bin_member_is_rejected() { + let bin = || vec![serde_bytes::Bytes::new(b"a")]; + let set = rmp_serde::to_vec(&SetPayload { values: bin() }).unwrap(); + let bytes = envelope::encode(SET_AGGREGATOR_KIND, &metadata_bytes(), &set); + assert!(SetAggregator::deserialize_from_bytes(&bytes).is_err()); + + let delta = rmp_serde::to_vec(&DeltaPayload { + added: vec![], + removed: bin(), + }) + .unwrap(); + let bytes = envelope::encode(SET_AGGREGATOR_DELTA_KIND, &metadata_bytes(), &delta); + assert!(DeltaResult::deserialize_from_bytes(&bytes).is_err()); + } + + #[test] + fn metadata_must_be_exactly_metadata_version_1() { + #[derive(Serialize)] + struct WithExtra { + metadata_version: u8, + bogus_field: u8, + } + let payload = rmp_serde::to_vec(&SetPayload::<&str> { values: vec![] }).unwrap(); + let metas = [ + rmp_serde::to_vec_named(&WithExtra { + metadata_version: 1, + bogus_field: 0, + }) + .unwrap(), + rmp_serde::to_vec_named(&SetMetadata { + metadata_version: 2, + }) + .unwrap(), + ]; + for meta in metas { + let bytes = envelope::encode(SET_AGGREGATOR_KIND, &meta, &payload); + assert!(SetAggregator::deserialize_from_bytes(&bytes).is_err()); + } + } + + /// A well-formed payload under another kind_id is rejected: the set under + /// the delta's, the delta under the set's or the retired `0x09 0x00`. + #[test] + fn each_decoder_rejects_a_foreign_kind_id() { + let set = crafted_set(&["a"]); + let delta = crafted_delta(&["a"], &[]); + let (_, metadata, payload) = envelope::split(&set).unwrap(); + let bytes = envelope::encode(SET_AGGREGATOR_DELTA_KIND, metadata, payload); + assert!(SetAggregator::deserialize_from_bytes(&bytes).is_err()); + for kind in [SET_AGGREGATOR_KIND, &[0x09, 0x00]] { + let (_, metadata, payload) = envelope::split(&delta).unwrap(); + let bytes = envelope::encode(kind, metadata, payload); + assert!(DeltaResult::deserialize_from_bytes(&bytes).is_err()); + } + } + + #[test] + fn delta_result_rejects_a_member_both_added_and_removed() { + let both = crafted_delta(&["a", "b"], &["b"]); + assert!(DeltaResult::deserialize_from_bytes(&both).is_err()); + let delta = DeltaResult { + added: set_of(&["a"]), + removed: set_of(&["a"]), + }; + assert!(delta.serialize_to_bytes().is_err()); + } + + #[test] + fn delta_result_rejects_unordered_sides() { + assert!(DeltaResult::deserialize_from_bytes(&crafted_delta(&["b", "a"], &[])).is_err()); + assert!(DeltaResult::deserialize_from_bytes(&crafted_delta(&[], &["c", "c"])).is_err()); + } +} diff --git a/tests/asapv1_golden.rs b/tests/asapv1_golden.rs index a57f307a..300916b3 100644 --- a/tests/asapv1_golden.rs +++ b/tests/asapv1_golden.rs @@ -8,13 +8,15 @@ //! //! See `asapv1_golden/README.md`. +use std::collections::HashSet; + use asap_sketchlib::sketches::CountSketchDelta; use asap_sketchlib::sketches::hll::HllWireVariant; use asap_sketchlib::{ - CMSHeap, CMSHeapDelta, CSHeap, CSHeapDelta, Classic, Count, CountMin, CountMinDelta, ErtlMLE, - FastPath, HHHeap, HeapCount, HeapItem, HllBucketListP14, HllSketch, HllVariant, - HyperLogLogDelta, HyperLogLogHIPP12, HyperLogLogP12, HyperLogLogP14, KLL, MessagePackCodec, - RegularPath, Vector2D, peek_kind_id, + CMSHeap, CMSHeapDelta, CSHeap, CSHeapDelta, Classic, Count, CountMin, CountMinDelta, + DeltaResult, ErtlMLE, FastPath, HHHeap, HeapCount, HeapItem, HllBucketListP14, HllSketch, + HllVariant, HyperLogLogDelta, HyperLogLogHIPP12, HyperLogLogP12, HyperLogLogP14, KLL, + MessagePackCodec, RegularPath, SetAggregator, Vector2D, peek_kind_id, }; fn decode_hex(s: &str) -> Vec { @@ -60,6 +62,10 @@ const GOLDEN_CSHEAP_F64: &str = include_str!("../asapv1_golden/csheap_f64_fast_2 const GOLDEN_CSHEAP_DELTA: &str = include_str!("../asapv1_golden/csheap_i64_regular_2x4_delta.hex"); const GOLDEN_KLL_F64: &str = include_str!("../asapv1_golden/kll_f64_k200.hex"); const GOLDEN_KLL_I64: &str = include_str!("../asapv1_golden/kll_i64_k200.hex"); +const GOLDEN_SET_STRINGS: &str = include_str!("../asapv1_golden/set_aggregator_strings.hex"); +const GOLDEN_SET_EMPTY: &str = include_str!("../asapv1_golden/set_aggregator_empty.hex"); +const GOLDEN_DELTA_STRINGS: &str = include_str!("../asapv1_golden/delta_result_strings.hex"); +const GOLDEN_DELTA_EMPTY: &str = include_str!("../asapv1_golden/delta_result_empty.hex"); /// The known P12 register pattern shared by all three HLL fixtures. fn p12_registers() -> Vec { @@ -678,6 +684,80 @@ fn kll_i64_k200_matches_golden() { assert_eq!(decoded.quantile(1.0), 50.0); } +// --------------------------------------------------------------------------- +// SetAggregator and its delta: set the members directly -> serialize == +// golden, and golden round-trips. +// --------------------------------------------------------------------------- + +fn set_of(members: &[&str]) -> HashSet { + members.iter().map(|m| m.to_string()).collect() +} + +fn set_aggregator_matches(golden: &str, members: &[&str]) { + let want = decode_hex(golden); + let set = SetAggregator { + values: set_of(members), + }; + assert_eq!(set.serialize_to_bytes().expect("serialize"), want); + + let decoded = SetAggregator::deserialize_from_bytes(&want).expect("decode"); + assert_eq!(decoded.values, set.values); + assert_eq!(decoded.serialize_to_bytes().expect("re-serialize"), want); +} + +fn delta_result_matches(golden: &str, added: &[&str], removed: &[&str]) { + let want = decode_hex(golden); + let delta = DeltaResult { + added: set_of(added), + removed: set_of(removed), + }; + assert_eq!(delta.serialize_to_bytes().expect("serialize"), want); + + let decoded = DeltaResult::deserialize_from_bytes(&want).expect("decode"); + assert_eq!(decoded.added, delta.added); + assert_eq!(decoded.removed, delta.removed); + assert_eq!(decoded.serialize_to_bytes().expect("re-serialize"), want); +} + +#[test] +fn set_aggregator_strings_matches_golden() { + set_aggregator_matches( + GOLDEN_SET_STRINGS, + &[ + "\u{1f600}", + "web", + "\u{ff5e}", + "abcdefghijklmnopqrstuvwxyz012345", + "", + "\u{4e2d}", + "abc", + "fixstr-max-31-bytes-0123456789a", + "\u{e9}", + "api", + "ab", + ], + ); +} + +#[test] +fn set_aggregator_empty_matches_golden() { + set_aggregator_matches(GOLDEN_SET_EMPTY, &[]); +} + +#[test] +fn delta_result_strings_matches_golden() { + delta_result_matches( + GOLDEN_DELTA_STRINGS, + &["\u{1f600}", "queue", "\u{e9}t\u{e9}"], + &["\u{4e2d}", "db", "", "cache"], + ); +} + +#[test] +fn delta_result_empty_matches_golden() { + delta_result_matches(GOLDEN_DELTA_EMPTY, &[], &[]); +} + // --------------------------------------------------------------------------- // peek_kind_id // --------------------------------------------------------------------------- From dc556e6f531d414fb66f37b246bebe29e9c0831c Mon Sep 17 00:00:00 2001 From: GordonYuanyc Date: Fri, 16 Oct 2026 16:07:40 +0000 Subject: [PATCH 7/7] feat(ddsketch): one ASAPv1 encoding and the DDSketch delta (05 80) DDSketch encodes as metadata_version 1 with all eight payload fields. DDSketchDelta carries bucket counts for both stores and the zero count; DDSketch::merge_delta applies one all or nothing, rejecting a delta whose stores would span past i32. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 + asapv1_golden | 2 +- docs/api/api_ddsketch.md | 56 ++- docs/asapv1_wire_format.md | 4 +- docs/ddsketch_signed_values.md | 2 +- docs/message_pack_format.md | 5 +- docs/tests.md | 20 +- src/message_pack_format/envelope.rs | 7 +- src/message_pack_format/native/ddsketch.rs | 2 +- src/message_pack_format/wire_key.rs | 6 - src/sketches/ddsketch.rs | 33 +- src/sketches/ddsketch/delta.rs | 318 +++++++++++++++++ src/sketches/ddsketch/wire.rs | 386 +++++++++++---------- src/sketches/mod.rs | 2 +- tests/asapv1_golden.rs | 141 +++++++- 15 files changed, 747 insertions(+), 244 deletions(-) create mode 100644 src/sketches/ddsketch/delta.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 90c31d69..4aa33d91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,9 +85,16 @@ signals a backwards-compatible change. `HyperLogLogImpl::merge_delta`. - **ASAPv1 for `SetAggregator` (`0x08 0x00`) and `DeltaResult` (`0x08 0x80`)**: `serialize_to_bytes` / `deserialize_from_bytes` on both types. +- **`DDSketchDelta`**, the ASAPv1 DDSketch delta (`0x05 0x80`): built with + `new` from changed buckets of both stores and a zero-count increment, + serialized like a sketch, and added with `DDSketch::merge_delta`, which + changes nothing when it fails. ### Changed +- **DDSketch has one ASAPv1 encoding**: `metadata_version` 1 with all eight + payload fields, the negative store and zero count included. Bytes change for + positive-only and empty sketches, and `metadata_version` 2 is rejected. - **The CMSHeap and CSHeap ASAPv1 metadata key `k` is renamed `heap_size`**, and the CMSHeap, CSHeap and UnivMon decoders reject heap entries out of emitted order. `HHItem` and `HHHeap` take a count type, `i64` by default. diff --git a/asapv1_golden b/asapv1_golden index 396b54dd..26d2a7c2 160000 --- a/asapv1_golden +++ b/asapv1_golden @@ -1 +1 @@ -Subproject commit 396b54ddf276aa43668c89d13d7d8592d0a927f0 +Subproject commit 26d2a7c2c6fa1387fcf52d22cdd25f2c2117afb2 diff --git a/docs/api/api_ddsketch.md b/docs/api/api_ddsketch.md index d4724210..a0a4054c 100644 --- a/docs/api/api_ddsketch.md +++ b/docs/api/api_ddsketch.md @@ -56,25 +56,53 @@ inputs, so the metadata carries **no hash-spec group**: it is `metadata_version` plus `alpha`, the sketch's one construction parameter. `gamma` and its logs are derived from `alpha` and never reach the wire. -The payload is `[counts, offset, sum, min, max]`: the dense bucket store carried -verbatim (the bucket index of `counts[i]` is `offset + i`), plus the three -scalars the buckets do not determine. `count` is **not** carried — it is the sum -of the bucket counts and is recovered exactly on decode. `sum`, `min` and `max` -come back exactly rather than as α-bounded bucket estimates, so a decoded sketch -re-serializes byte-identically. - -There is one positive-range store and no zero-count bucket, because `add` drops -non-positive, non-finite and non-indexable values. Bucket indices are still -signed, so `offset` may be negative. Decode rejects an `alpha` outside `(0, 1)`, -a store span past `i32`, a non-zero offset on an empty store, bucket counts that -overflow the total, and scalars inconsistent with that total; `serialize_to_bytes` -enforces the same rules, so the format never emits bytes it would refuse to read -back. +Every payload is `[counts, offset, sum, min, max, negative_counts, +negative_offset, zero_count]`: the positive store and the negative-magnitude +store carried verbatim (the bucket index of `counts[i]` is `offset + i`), the +three scalars the buckets do not determine, and the zero count. `count` is +**not** carried — it is the sum of both stores and `zero_count`, recovered +exactly on decode. `sum`, `min` and `max` come back exactly rather than as +α-bounded bucket estimates, so a decoded sketch re-serializes byte-identically. + +Bucket indices are signed, so either offset may be negative. Decode rejects a +`metadata_version` other than `1`, an `alpha` outside `(0, 1)`, a store span +past `i32`, a non-zero offset on an empty store, counts that overflow the total, +and scalars inconsistent with that total; `serialize_to_bytes` enforces the same +rules, so the format never emits bytes it would refuse to read back. Independently of ASAPv1, `DDSketch` is plain `Serialize`/`Deserialize` for use where it is nested in another type (`EHSketchList`); that form skips the four running scalars. +## Deltas + +```rust +DDSketchDelta + +fn new( + alpha: f64, + indices: Vec, + deltas: Vec, + negative_indices: Vec, + negative_deltas: Vec, + zero_count: u64, +) -> Result +fn serialize_to_bytes(&self) -> Result, RmpEncodeError> +fn deserialize_from_bytes(bytes: &[u8]) -> Result + +// on DDSketch +fn merge_delta(&mut self, delta: &DDSketchDelta) -> Result<(), String> +``` + +A `DDSketchDelta` is the ASAPv1 delta kind `0x05 0x80`: the changed buckets of +each store as strictly ascending bucket indices inside the range `alpha`'s +mapping produces, the non-zero count added to each, and a count added to the +zero count. `new` and the decoder refuse anything else. `merge_delta` adds each +count to its bucket, growing the store as insertion does, and folds the +bucket's representative into `sum`, `min` and `max`; a delta of another +`alpha`, one that overflows the total count, or one that would grow a store +past `i32`, fails and leaves the sketch unchanged. + ## Examples ```rust diff --git a/docs/asapv1_wire_format.md b/docs/asapv1_wire_format.md index e84708b3..bdbb77b7 100644 --- a/docs/asapv1_wire_format.md +++ b/docs/asapv1_wire_format.md @@ -26,7 +26,7 @@ If the doc feels long, these sections carry the key points: ## Status -- **Implemented (Rust).** Every kind_id the Section 1 registry marks *implemented* serializes through the shared `message_pack_format::envelope` module per this spec: HLL (three estimators) with the Classic and Ertl-MLE deltas (`HyperLogLogDelta`), Count-Min with its delta (`CountMinDelta`), CMSHeap with its delta (`CMSHeapDelta`), Count Sketch with its delta (`sketches::CountSketchDelta`), CSHeap with its delta (`CSHeapDelta`), DDSketch, KLL (compact + dynamic), Hydra (five counter variants), Elastic, Coco, UniformSampling, KMV, UnivMon, UnivMon Optimized, ExponentialHistogram, EHSketchList, Bloom, Space-Saving, CountL2HH, UnivMon-Q, and SetAggregator with its delta (`DeltaResult`). +- **Implemented (Rust).** Every kind_id the Section 1 registry marks *implemented* serializes through the shared `message_pack_format::envelope` module per this spec: HLL (three estimators) with the Classic and Ertl-MLE deltas (`HyperLogLogDelta`), Count-Min with its delta (`CountMinDelta`), CMSHeap with its delta (`CMSHeapDelta`), Count Sketch with its delta (`sketches::CountSketchDelta`), CSHeap with its delta (`CSHeapDelta`), DDSketch with its delta (`DDSketchDelta`), KLL (compact + dynamic), Hydra (five counter variants), Elastic, Coco, UniformSampling, KMV, UnivMon, UnivMon Optimized, ExponentialHistogram, EHSketchList, Bloom, Space-Saving, CountL2HH, UnivMon-Q, and SetAggregator with its delta (`DeltaResult`). - **Self-describing.** The hash-spec metadata is derived from the hasher's `HashProfile` (read live, never hardcoded), so the bytes truthfully describe how a sketch was hashed; custom hash profiles are supported (Section 2). - **Byte-level encoding** is pinned in Section 4; the resolved decisions are summarized at the end. - **`sketchlib-go`** is aligned separately (see Cross-language contract). @@ -195,7 +195,7 @@ This registry is the master list of algorithms still to design payloads for, and | `0x04 0x00` | Count Sketch | Count Sketch | Section 3.6 | implemented | | `0x04 0x80` | Count Sketch | Count Sketch delta | Section 3.23 | implemented | | `0x05 0x00` | DDSketch | Logarithmic bucket stores | Section 3.8 | implemented | -| `0x05 0x80` | DDSketch | DDSketch delta | Section 3.23 | designed | +| `0x05 0x80` | DDSketch | DDSketch delta | Section 3.23 | implemented | | `0x06 0x00` | KLL | Compact | Section 3.3 | implemented | | `0x06 0x01` | KLL dynamic | Dynamic | Section 3.3 | implemented | | `0x07 0x00` | Hydra | KLL counter | Section 3.9 | implemented | diff --git a/docs/ddsketch_signed_values.md b/docs/ddsketch_signed_values.md index 923eddaa..fa05a06b 100644 --- a/docs/ddsketch_signed_values.md +++ b/docs/ddsketch_signed_values.md @@ -10,7 +10,7 @@ If interpolation combines endpoints x and y with weight t, the absolute error is ## Wire and downstream upgrade -- Native ASAPv1: positive-only states keep metadata version 1 and their existing bytes. Signed/zero states use metadata version 2 and append negative counts, negative offset, and zero count. Old native readers reject version 2. +- Native ASAPv1: every state is metadata version 1 with the eight-field payload, negative counts, negative offset and zero count included. `DDSketchDelta` (`0x05 0x80`) carries both stores and a zero-count increment. - Portable MessagePack: positive-only snapshots keep their three fields; signed snapshots append the same three fields. Positive-only deltas keep their two arrays; signed deltas append negative indices, negative counts, and zero-count increment. New readers accept both layouts. - Protobuf: `DdSketchState` adds fields 16–18; `DdSketchDelta` adds fields 16–17. `DdSketch::to_proto` / `from_proto` preserve all stores. Old protobuf readers silently ignore unknown fields: upgrade Collector, backend, and Go producer schema/mapping together before emitting signed protobuf. This is not DataDog's binary wire format. - Octo: `DdDelta` now requires a `store: DdStore` discriminator. Existing positive delta constructors should use `DdStore::Positive`. Worker residual keys are `(DdStore, i32)`. Both promotion and flush preserve all three categories; partial counts still need flushing before complete queries. diff --git a/docs/message_pack_format.md b/docs/message_pack_format.md index b934526f..89e44462 100644 --- a/docs/message_pack_format.md +++ b/docs/message_pack_format.md @@ -115,8 +115,9 @@ Converted: - **CMSHeap** (`src/sketches/countminsketch_topk/wire.rs`, `0x03 0x00`) and **CSHeap** (`src/sketches/countsketch_topk/wire.rs`, `0x0a 0x00`) — a base matrix inlined ahead of the shared top-k heap entries. -- **DDSketch** (`src/sketches/ddsketch/wire.rs`, `0x05 0x00`) — the bucket store - verbatim plus `offset`, `sum`, `min` and `max`; no hash-spec group. +- **DDSketch** (`src/sketches/ddsketch/wire.rs`, `0x05 0x00`) — both bucket + stores verbatim with their offsets, `sum`, `min`, `max` and `zero_count`; no + hash-spec group. - **Elastic** (`src/sketches/elastic/wire.rs`, `0x0b 0x00`) — the heavy table's four parallel arrays, the `stale_copies` flag, and the inlined light Count-Min. - **Coco** (`src/sketches/coco/wire.rs`, `0x0c 0x00`) — the dense bucket table as diff --git a/docs/tests.md b/docs/tests.md index f1428407..07d722b6 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -204,6 +204,8 @@ Test file: [`src/sketches/ddsketch.rs`](../src/sketches/ddsketch.rs) Wire tests: [`src/sketches/ddsketch/wire.rs`](../src/sketches/ddsketch/wire.rs) +Delta tests: [`src/sketches/ddsketch/delta.rs`](../src/sketches/ddsketch/delta.rs) + | test_name | test_description | what_is_tested | | --- | --- | --- | | `insert_and_query_basic` | Basic insert/query preserves count semantics and quantile monotonicity. | Inserts mixed values `[0.0, -5.0, 1.0, 2.0, 3.0, 10.0, 50.0, 100.0, 1000.0]`, verifies non-positive values are ignored (`count == 7`), and checks queried quantiles at `{0.0, 0.5, 0.9, 0.99, 1.0}` are monotone and bounded by sketch min/max. | @@ -212,17 +214,20 @@ Wire tests: [`src/sketches/ddsketch/wire.rs`](../src/sketches/ddsketch/wire.rs) | `dds_serialization_round_trip` | Serialization round trip preserves count, sum, bounds, and selected quantiles. | Serializes/deserializes a populated sketch (`alpha=0.01`), verifies non-empty bytes, equal `count/sum/min/max`, and exact quantile matches at `{0.0, 0.1, 0.5, 0.9, 1.0}`. | | `ddsketch_envelope_structure_and_round_trip` | The envelope frames a sketch under kind_id `0x05 0x00`, and the bytes are stable across a round trip. | For a sketch at `alpha = 0.01` over eight values, verifies the bytes open with the ASAPv1 magic, a `kind_id_len` of `2`, and `0x05 0x00`, that the metadata carries `metadata_version` `1` and that alpha, that the decoded store counts and offset match, that re-serializing reproduces the bytes, and that quantiles at `{0, 0.1, 0.5, 0.9, 1}` are unchanged. | | `ddsketch_scalars_survive_exactly` | `sum`, `min`, and `max` are carried, not recomputed from the buckets. | Verifies the decoded sketch reports the source's `sum` and `alpha`, a `min` of exactly `0.25`, and a `max` of exactly `1000.0`, rather than the alpha-bounded bucket representatives a recomputation would give. | -| `ddsketch_count_is_recovered_from_the_buckets` | The payload carries no `count` field. | Verifies the payload reads back as a five-element array whose bucket counts sum to `get_count()`, that a six-element read fails, and that the decoded sketch reports the same count. | +| `ddsketch_payload_is_eight_fields_without_a_count` | A positive-only sketch carries all eight payload fields and no `count`. | Verifies the payload reads back as an eight-element array whose bucket counts sum to `get_count()`, with an empty negative store at offset `0` and a zero `zero_count`, and that the decoded sketch reports the same count. | | `ddsketch_empty_round_trip` | A sketch that saw nothing round-trips. | Verifies a fresh `DDSketch(alpha=0.01)` decodes back with a `count` of `0`, `min` and `max` of `None`, an empty store, and identical re-serialized bytes. | | `ddsketch_merged_round_trip` | A store grown on both sides of a merge round-trips. | Merges 200 values against the same 200 scaled by `0.001`, then verifies the decoded sketch matches the source on `count`, `sum`, `min`, and `max` and re-serializes to the same bytes. | -| `ddsketch_rejects_foreign_kind_id` | Another sketch's kind_id is refused even when the rest parses cleanly. | Verifies a `3x8` Count-Min envelope fails to decode as a `DDSketch`, and that well-formed DDSketch metadata and payload wrapped under kind_id `0x02 0x00` fail too. | +| `ddsketch_rejects_foreign_kind_id` | Another sketch's kind_id is refused even when the rest parses cleanly. | Verifies a `3x8` Count-Min envelope fails to decode as a `DDSketch`, and that an empty DDSketch envelope decodes but fails with its kind_id relabelled `0x02 0x00`. | | `dd_metadata_rejects_unknown_keys` | An unexpected metadata key fails closed. | Encodes the two `DdMetadata` fields plus a `bogus_field` as a named map and verifies it does not decode as `DdMetadata`. | | `dd_metadata_rejects_a_missing_alpha_key` | `alpha` is required and can never be silently defaulted. | Encodes a named map holding only `metadata_version` and verifies it does not decode as `DdMetadata`. | +| `ddsketch_rejects_another_metadata_version` | `metadata_version` is `1`. | Verifies a valid payload under `metadata_version` `0` and `2` each fails with a metadata-mismatch complaint. | | `ddsketch_rejects_alpha_outside_the_unit_interval` | An `alpha` outside `(0, 1)` makes every bucket index meaningless and is refused. | Verifies crafted envelopes at `alpha` of `0.0`, `1.0`, `-0.5`, `2.0`, `NaN`, and infinity each fail with an alpha or metadata-mismatch complaint, while the same payload at `0.01` decodes. | -| `ddsketch_rejects_a_store_span_past_i32` | The span rule fires from the offset and the array length, before the store is rebuilt. | Verifies a crafted store of ten buckets at offset `i32::MAX - 2` fails with a "store span past i32" complaint, while the same payload three buckets long decodes. | +| `ddsketch_checks_alpha_before_the_payload` | `alpha` is checked before the payload is read. | Verifies `alpha` `2.0` in front of an unreadable one-byte payload fails with an alpha complaint. | +| `ddsketch_rejects_a_store_span_past_i32` | The span rule fires from the offset and the array length, before the store is rebuilt. | Verifies a crafted positive store and a crafted negative store of ten buckets at offset `i32::MAX - 2` each fail with a "store span past i32" complaint, while the positive payload three buckets long decodes. | | `ddsketch_rejects_a_nonzero_offset_on_an_empty_store` | An empty store has exactly one encoding. | Verifies a crafted empty store at offset `42` fails with an "empty store must be at offset 0" complaint. | -| `ddsketch_rejects_a_total_count_overflow` | Bucket counts that overflow the recovered total are refused rather than wrapped. | Verifies a crafted payload of `[u64::MAX, 1]` fails with an "overflow the total sample count" complaint. | +| `ddsketch_rejects_a_total_count_overflow` | Counts that overflow the recovered total are refused rather than wrapped. | Verifies a positive store `[u64::MAX, 1]`, a positive `[u64::MAX]` beside a negative `[1]`, and a positive `[u64::MAX]` beside a `zero_count` of `1` each fail with an "overflow the total sample count" complaint. | | `ddsketch_rejects_inconsistent_scalars` | The scalars the buckets do not determine are still bounded by them. | Verifies six crafted payloads each fail on a scalar rule: a populated store carrying the empty-store sentinels, `min` above `max`, a non-positive `min`, a `sum` below the smallest ingested value, an empty store carrying a non-zero `sum`, and all-zero buckets carrying populated-store scalars. | +| `ddsketch_positive_scalar_checks_follow_the_counts` | `0 < min` and `sum >= min` apply only while the negative store sums to zero and `zero_count` is zero. | Verifies a negative `min` beside an all-zero negative store `[0, 0]` fails with a "scalars out of order" complaint, and that a negative `min` beside a negative `[1]` and a `min` of `0.0` beside a `zero_count` of `2` each decode and re-serialize identically. | | `ddsketch_rejects_serializing_a_store_span_past_i32` | The encode side refuses a store its own decoder would reject. | Verifies a sketch whose store holds ten buckets at offset `i32::MAX - 2` fails to serialize. | | `ddsketch_rejects_serializing_an_overflowing_bucket_total` | The encode side enforces the total-count rule too. | Verifies a sketch whose store holds `[u64::MAX, 1]` fails to serialize. | | `representative_within_alpha_at_bucket_edges` | Every bucket's representative is within `alpha` of both of its edges. | For `alpha` in `{0.001, 0.01, 0.05, 0.1}` and bucket indices `{-100, -1, 0, 1, 7, 500}`, verifies `bin_representative(k)` lies in `[lower_bound(k), lower_bound(k + 1)]` and its relative error against each edge is at most `alpha + 1e-9`. | @@ -232,6 +237,11 @@ Wire tests: [`src/sketches/ddsketch/wire.rs`](../src/sketches/ddsketch/wire.rs) | `serde_round_trip_leaves_the_sketch_usable` | A decoded sketch keeps ingesting on top of the state it came back with. | Adds `5.0` to the decode of an `alpha = 0.01` sketch over `[0.25, 1, 2, 3, 10, 50, 100, 1000]` and verifies `get_count` rises by `1`, `sum` by `5.0`, and that the bucket counts still sum to `get_count`. | | `serde_refuses_scalars_that_disagree_with_the_store` | Serde checks the scalars against the store the way the ASAPv1 decoder does. | Encodes a crafted state at `alpha = 0.01` whose store holds `[1, 2]` at offset `-3` while `sum` is `0.0` with the empty-store sentinels `+inf`/`-inf` for `min`/`max`, and verifies the decode fails with a complaint naming "DDSketch scalars". | | `serde_refuses_an_out_of_range_alpha` | An `alpha` outside `(0, 1)` is refused on the way in, not at the first query. | Encodes a crafted state at `alpha = 1.5` with an empty store at offset `0` and the empty-store scalars, and verifies the decode fails with a complaint naming "alpha". | +| `decode_rejects_each_payload_rule` | A `0x05 0x80` payload breaking any delta rule fails to decode. | At `alpha = 0.01`, verifies indices `[-35418, 35448]` (`key(min_v)`, `key(max_v)`) in both stores decode, and that index `-35419`, index `35449`, and negative-store index `35449` each fail. | +| `decode_rejects_another_kind_and_new_another_alpha` | A delta decodes only under its own kind_id, and `new` checks `alpha`. | Verifies a delta envelope decodes and fails with its kind_id relabelled `0x05 0x00`, and that `new` refuses `alpha` `1.0`. | +| `merge_delta_folds_the_scalars` | Applying a delta folds each representative, and `0.0` for zeros, into the scalars. | Merges a count of `2` at bucket `10` into an empty sketch and verifies `min`, `max` equal `r = 1.01 * gamma^10` and `sum` equals `2r`; then merges a `zero_count` of `3` and verifies `min` `0.0`, `zero_count` `3` and `get_count()` `5`. | +| `merge_delta_rejects_without_changing_the_state` | A rejected delta leaves the sketch unchanged. | Verifies a delta whose `alpha` is one ulp above `0.01` fails on an `alpha = 0.01` sketch, and that on a sketch holding `u64::MAX - 1` samples a delta whose negative count overflows the total and a delta whose `zero_count` overflows it each fail, leaving the count and the serialized bytes as they were. | +| `merge_delta_rejects_a_store_span_past_i32` | A delta that would grow a store past `i32` fails instead of overflowing. | Verifies a delta at index `-10` onto a decoded state whose positive store is `[1]` at offset `i32::MAX - 1` fails, leaving the serialized bytes as they were. | ### CMSHeap @@ -2183,6 +2193,8 @@ Test file: [`tests/asapv1_golden.rs`](../tests/asapv1_golden.rs) | `csheap_f64_fast_2x4_strkeys_matches_golden` | `f64` heap counts are the unrounded signed median, a tie broken by the key. | Verifies an `f64` fast-path `CSHeap` over the `2x4` f64 Count Sketch matrix with heap size `4` holding `{"alpha": 63.5625, "aa": -0.75, "beta": -0.75, "gamma": -16384.25}` serializes to `asapv1_golden/csheap_f64_fast_2x4_strkeys.hex`, and that the decode holds that matrix and those entries and re-serializes identically. | | `csheap_i64_regular_2x4_delta_matches_golden` | A known CSHeap delta serializes to its golden bytes and, merged, replaces the state's heap. | Verifies `CSHeapDelta::::new(2, 4, [0, 1, 2, 4, 6, 7], [-32, -128, 255, -129, 65535, -2147483649], heap)` with heap size `5` holding `i64` keys `{-1: 65536, 7: -33, -129: -4294967297}` serializes to `asapv1_golden/csheap_i64_regular_2x4_delta.hex`, that the fixture decodes and re-serializes identically, and that merged into the `string`-keyed `csheap_i64_regular_2x4_strkeys` it gives `[-32, -1, 383, 65536, -130, -33, 32767, -4294967297]` and exactly the delta's heap. | | `csheap_i64_regular_2x4_strkeys_matches_golden` | A CSHeap serializes to its golden bytes. | Verifies the `2x4` Count Sketch matrix with heap size `5` holding `{"alpha": 4294967296, "beta": 127, "delta": 127, "gamma": -33}` serializes to `asapv1_golden/csheap_i64_regular_2x4_strkeys.hex`, and that the decode holds that matrix and those entries and re-serializes identically. | +| `ddsketch_{positive, signed, empty}_a001_matches_golden` | The DDSketch fixtures serialize to their golden bytes. | For each, verifies an `alpha = 0.01` `DDSketch` with the fixture's stores, zero count and scalars set through its serde form serializes to `asapv1_golden/ddsketch__a001.hex`, and that decoding the fixture re-serializes to the same bytes. | +| `ddsketch_signed_a001_delta_matches_golden` | A known DDSketch delta serializes to its golden bytes and merges into the signed state golden. | Verifies `DDSketchDelta::new(0.01, [-300, -100, -5, 7, 200, 400], [1, 127, 128, 256, 65536, 4294967296], [-208, -33, -1, 0, 130, 1000], [2, 3, 3, 1, 3, 1], 9)` serializes to `asapv1_golden/ddsketch_signed_a001_delta.hex`, that the fixture decodes and re-serializes identically, and that merged into `ddsketch_signed_a001` it gives the summed non-zero buckets of both stores, `zero_count` `16`, `get_count()` `4295033384`, `sum` folded in payload order from `r = 1.01 * gamma^k`, `min` `-r(1000)`, and `max` `r(400)`. | | `delta_result_empty_matches_golden` | An empty delta serializes to its golden bytes, which round-trip unchanged. | Verifies a `DeltaResult` with both sets empty serializes to `asapv1_golden/delta_result_empty.hex` (kind_id `0x08 0x80`), and that decoding the fixture yields two empty sets and re-serializes to the same bytes. | | `delta_result_strings_matches_golden` | A delta's two sides serialize in ascending UTF-8 byte order to the golden bytes. | Verifies a `DeltaResult` with `added` `{"😀", "queue", "été"}` and `removed` `{"中", "db", "", "cache"}` serializes to `asapv1_golden/delta_result_strings.hex`, and that decoding the fixture recovers both sets and re-serializes to the same bytes. | | `hll_classic_p12_matches_golden` | A known P12 Classic register pattern serializes to its golden bytes on both the portable and native paths. | Builds `HllSketch::from_raw(HllVariant::Regular, 12, regs, 0.0, 0.0, 0.0)` where `regs` is 4,096 zeros with `[0] = 1`, `[1] = 7`, `[100] = 42` and `[4095] = 3`, verifies `to_msgpack()` equals `asapv1_golden/hll_classic_p12.hex`, that `HyperLogLogP12::::deserialize_from_bytes` on the fixture recovers those registers and re-serializes identically, and that `HllSketch::from_msgpack` reports the same registers, `precision` `12` and `HllVariant::Regular`. | diff --git a/src/message_pack_format/envelope.rs b/src/message_pack_format/envelope.rs index 2b530151..ddf20ae6 100644 --- a/src/message_pack_format/envelope.rs +++ b/src/message_pack_format/envelope.rs @@ -231,6 +231,9 @@ mod tests { "../../asapv1_golden/set_aggregator_strings.hex" )); let delta = hex(include_str!("../../asapv1_golden/delta_result_strings.hex")); + let dd_delta = hex(include_str!( + "../../asapv1_golden/ddsketch_signed_a001_delta.hex" + )); let str_keys = ["alpha", "beta", "gamma", "alpha", "alpha"].map(DataInput::Str); let bytes_keys = [b"ab", b"cd", b"ab"].map(|key| DataInput::Bytes(key)); @@ -266,7 +269,6 @@ mod tests { elastic.insert("flow::a".to_string()); let mut kll_dynamic = KLLDynamic::::init_kll(200); let mut positive = DDSketch::new(0.01); - let mut signed = DDSketch::new(0.01); let config = UnivMonQConfig { levels: 4, width: 64, @@ -281,7 +283,6 @@ mod tests { for value in [1.0, 2.5, 7.0] { kll_dynamic.update(&value); positive.add(&value); - signed.add(&-value); univmon_q.update(&value); } let cm = CountMin::, FastPath>::with_dimensions(3, 8); @@ -321,7 +322,7 @@ mod tests { sketch!("CSHeap", cs_heap, CsHeap), sketch!("CountL2HH", l2hh, CountL2HH), sketch!("DDSketch", positive, DDSketch), - sketch!("DDSketch signed", signed, DDSketch), + case!("DDSketch delta", dd_delta, DDSketchDelta), sketch!("Bloom", bloom, Bloom), sketch!("SpaceSaving string", ss, SpaceSaving), sketch!("SpaceSaving u64", ss_u64, SpaceSaving), diff --git a/src/message_pack_format/native/ddsketch.rs b/src/message_pack_format/native/ddsketch.rs index 9a88bd74..7889c3a0 100644 --- a/src/message_pack_format/native/ddsketch.rs +++ b/src/message_pack_format/native/ddsketch.rs @@ -1,7 +1,7 @@ //! Native MessagePack codec impl for [`crate::sketches::ddsketch::DDSketch`]. //! //! Produces and consumes the ASAPv1 envelope (kind_id `0x05 0x00`): `alpha` in -//! the metadata, `[counts, offset, sum, min, max]` in the payload. +//! the metadata, the eight-field payload of the wire spec §3.8. use crate::message_pack_format::{Error, MessagePackCodec}; use crate::sketches::ddsketch::DDSketch; diff --git a/src/message_pack_format/wire_key.rs b/src/message_pack_format/wire_key.rs index 37b4f354..1550e816 100644 --- a/src/message_pack_format/wire_key.rs +++ b/src/message_pack_format/wire_key.rs @@ -325,7 +325,6 @@ mod tests { type CmsF64 = CountMin, FastPath>; type HeapF64 = CMSHeap, RegularPath>; let mut positive = DDSketch::new(0.25); - let mut signed = DDSketch::new(0.25); let mut kll = KLL::::init_kll_with_seed(200, 1); let mut heap = HeapF64::from_storage(Vector2D::from_fn(2, 4, |r, c| (r * 4 + c) as f64), 4); let mut space_saving: SpaceSaving = SpaceSaving::with_capacity(4); @@ -334,7 +333,6 @@ mod tests { .expect("schema"); for value in [1.0, 2.0] { positive.add(&value); - signed.add(&-value); } for value in [1.0, 2.5, 7.0] { kll.update(&value); @@ -353,7 +351,6 @@ mod tests { .expect("arity"); let dd = &positive.serialize_to_bytes().unwrap(); - let sdd = &signed.serialize_to_bytes().unwrap(); let hip = &hex(include_str!("../../asapv1_golden/hll_hip_p12.hex")); let cms = &hex(include_str!("../../asapv1_golden/cms_f64_fast_2x3.hex")); let cms_delta = &hex(include_str!( @@ -391,9 +388,6 @@ mod tests { row("DDSketch sum", dd, 3.0, Int, dd_ok), row("DDSketch min", dd, 1.0, Int, dd_ok), row("DDSketch max", dd, 2.0, F32, dd_ok), - row("DDSketch signed sum", sdd, -3.0, Int, dd_ok), - row("DDSketch signed min", sdd, -2.0, Int, dd_ok), - row("DDSketch signed max", sdd, -1.0, F32, dd_ok), row("HLL hip_kxq0", hip, 1.5, F32, hip_ok), row("HLL hip_kxq1", hip, 2.5, F32, hip_ok), row("HLL hip_est", hip, 3.0, Int, hip_ok), diff --git a/src/sketches/ddsketch.rs b/src/sketches/ddsketch.rs index bc6dc56c..70259497 100644 --- a/src/sketches/ddsketch.rs +++ b/src/sketches/ddsketch.rs @@ -22,9 +22,13 @@ use crate::common::structures::Vector1D; use crate::octo_delta::{DdDelta, DdStore}; use serde::{Deserialize, Serialize}; +/// The ASAPv1 DDSketch delta (kind_id `0x05 0x80`). +mod delta; /// ASAPv1 wire serialization (kind_id `0x05 0x00`). mod wire; +pub use delta::DDSketchDelta; + // Number of buckets to grow by when expanding. const GROW_CHUNK: usize = 128; @@ -118,14 +122,13 @@ impl Buckets { /// Mergeable, relative-error quantile sketch using logarithmically-spaced buckets. /// /// ASAPv1 serialization lives in the `wire` submodule (kind_id `0x05 0x00`); it -/// carries `alpha`, the bucket store, and `sum` / `min` / `max`, and recovers -/// `count` by summing the buckets. +/// carries `alpha`, both bucket stores, the zero count and `sum` / `min` / +/// `max`, and recovers `count` by summing them. /// /// The derived `Serialize` / `Deserialize` is a separate, Rust-internal form /// used where a `DDSketch` is nested in another type. It carries the same state -/// as the ASAPv1 payload — `alpha`, the bucket store, and `sum` / `min` / `max` -/// — and rebuilds the index mapping and `count` on the way in, refusing a state -/// the ASAPv1 decoder would refuse. +/// as the ASAPv1 payload and rebuilds the index mapping and `count` on the way +/// in, refusing a state the ASAPv1 decoder would refuse. #[derive(Debug, Serialize, Deserialize)] #[serde(try_from = "DDSketchState")] pub struct DDSketch { @@ -169,25 +172,7 @@ impl TryFrom for DDSketch { type Error = String; fn try_from(state: DDSketchState) -> Result { - wire::check_alpha(state.alpha)?; - let counts = state.store.counts.as_slice(); - wire::check_store_span(state.store.offset, counts.len())?; - wire::check_store_span( - state.negative_store.offset, - state.negative_store.counts.len(), - )?; - let negative_count = wire::total_count(state.negative_store.counts.as_slice()) - .ok_or("DDSketch count overflow")?; - let count = wire::total_count(counts) - .and_then(|c| c.checked_add(negative_count)) - .and_then(|c| c.checked_add(state.zero_count)) - .ok_or_else(|| "DDSketch bucket counts overflow the total sample count".to_string())?; - if negative_count == 0 && state.zero_count == 0 { - wire::check_scalars(count, state.sum, state.min, state.max)?; - } else { - wire::check_signed_scalars(count, state.sum, state.min, state.max)?; - } - + let count = wire::check_state(&state)?; let gamma = (1.0 + state.alpha) / (1.0 - state.alpha); let log_gamma = gamma.ln(); Ok(Self { diff --git a/src/sketches/ddsketch/delta.rs b/src/sketches/ddsketch/delta.rs new file mode 100644 index 00000000..491c4d9d --- /dev/null +++ b/src/sketches/ddsketch/delta.rs @@ -0,0 +1,318 @@ +//! The DDSketch delta, ASAPv1 kind `0x05 0x80` (`docs/asapv1_wire_format.md` +//! §3.23). Its metadata is the state's (§3.8); its payload is +//! `[indices, deltas, negative_indices, negative_deltas, zero_count]`. + +use std::ops::RangeInclusive; + +use rmp_serde::{decode::Error as RmpDecodeError, encode::Error as RmpEncodeError}; +use serde::{Deserialize, Serialize}; + +use crate::message_pack_format::delta::check_cells; +use crate::message_pack_format::envelope; + +use super::wire::{check_alpha, decode_metadata, encode_metadata}; +use super::{Buckets, DDSketch, GROW_CHUNK, ddsketch_indexable_bounds}; + +/// DDSketch delta kind_id: the state's `0x05 0x00` with the variant's top bit +/// set. +const DD_DELTA_KIND: &[u8] = &[0x05, 0x80]; + +/// A change to a [`DDSketch`] state of relative accuracy `alpha`: each count of +/// `deltas` is added to the positive-store bucket its position names in +/// `indices`, each of `negative_deltas` to the negative-store bucket named in +/// `negative_indices`, and `zero_count` to the zero count. Each index array is +/// strictly ascending and inside the mapping's index range, and no count in +/// `deltas` or `negative_deltas` is zero. +#[derive(Clone, Debug)] +pub struct DDSketchDelta { + alpha: f64, + indices: Vec, + deltas: Vec, + negative_indices: Vec, + negative_deltas: Vec, + zero_count: u64, +} + +/// The delta payload: `[indices, deltas, negative_indices, negative_deltas, +/// zero_count]`. +#[derive(Serialize, Deserialize)] +struct DdDeltaPayload { + indices: Vec, + deltas: Vec, + negative_indices: Vec, + negative_deltas: Vec, + zero_count: u64, +} + +/// The bucket indices `alpha`'s mapping produces: `key(min_v)..=key(max_v)` +/// over the indexable values. +fn index_range(alpha: f64) -> RangeInclusive { + let inv_log_gamma = 1.0 / ((1.0 + alpha) / (1.0 - alpha)).ln(); + let key = |v: f64| (v.ln() * inv_log_gamma).floor() as i32; + let (min_v, max_v) = ddsketch_indexable_bounds(alpha); + key(min_v)..=key(max_v) +} + +/// Whether `store` stays inside `i32` — offset, highest index and length — +/// after [`Buckets::ensure`] grows it for each of `indices` in turn, computed +/// in `i64` without touching the store. +fn grows_within_i32(store: &Buckets, indices: &[i32]) -> bool { + let chunk = GROW_CHUNK as i64; + let mut span = store.range().map(|(l, r)| (i64::from(l), i64::from(r))); + for &k in indices { + let k = i64::from(k); + let (l, r) = match span { + None => (k - chunk / 2, k + chunk / 2 - 1), + Some((l, r)) if k < l => (l - (l - k).max(chunk), r), + Some((l, r)) if k > r => (l, r + (k - r).max(chunk)), + Some(lr) => lr, + }; + if [l, r, r - l].iter().any(|&v| i32::try_from(v).is_err()) { + return false; + } + span = Some((l, r)); + } + true +} + +impl DDSketchDelta { + /// Builds a delta from the changed buckets of a sketch of relative accuracy + /// `alpha`. Fails when `alpha` is outside `(0, 1)` or the buckets break the + /// rules above. + pub fn new( + alpha: f64, + indices: Vec, + deltas: Vec, + negative_indices: Vec, + negative_deltas: Vec, + zero_count: u64, + ) -> Result { + check_alpha(alpha).map_err(|e| format!("DDSketch delta: {e}"))?; + let range = index_range(alpha); + check_cells(&indices, &deltas, range.clone()) + .map_err(|e| format!("DDSketch delta: {e}"))?; + check_cells(&negative_indices, &negative_deltas, range) + .map_err(|e| format!("DDSketch delta: negative store: {e}"))?; + Ok(Self { + alpha, + indices, + deltas, + negative_indices, + negative_deltas, + zero_count, + }) + } + + /// Serializes the delta into an ASAPv1 MessagePack envelope. + pub fn serialize_to_bytes(&self) -> Result, RmpEncodeError> { + let metadata = encode_metadata(self.alpha)?; + let payload = rmp_serde::to_vec(&DdDeltaPayload { + indices: self.indices.clone(), + deltas: self.deltas.clone(), + negative_indices: self.negative_indices.clone(), + negative_deltas: self.negative_deltas.clone(), + zero_count: self.zero_count, + })?; + Ok(envelope::encode(DD_DELTA_KIND, &metadata, &payload)) + } + + /// Deserializes a delta from an ASAPv1 MessagePack envelope. + pub fn deserialize_from_bytes(bytes: &[u8]) -> Result { + let (kind_id, metadata, payload) = + envelope::split(bytes).map_err(RmpDecodeError::Uncategorized)?; + if kind_id != DD_DELTA_KIND { + return Err(RmpDecodeError::Uncategorized(format!( + "DDSketch delta kind_id mismatch: stored {kind_id:?}, expected {DD_DELTA_KIND:?}" + ))); + } + let alpha = decode_metadata(metadata)?; + let p: DdDeltaPayload = envelope::decode_block(payload)?; + Self::new( + alpha, + p.indices, + p.deltas, + p.negative_indices, + p.negative_deltas, + p.zero_count, + ) + .map_err(RmpDecodeError::Uncategorized) + } +} + +impl DDSketch { + /// Adds a delta to the sketch. Each positive bucket, then each negative + /// one, adds its count `c` and folds the bucket representative `r` into the + /// scalars (`sum += r * c`, saturating; `min` / `max`); a non-zero + /// `zero_count` adds to the zero count and folds `0.0` into `min` / `max`. + /// Fails, changing nothing, when the delta's `alpha` differs, the total + /// sample count overflows, or a store would span past `i32`. + pub fn merge_delta(&mut self, delta: &DDSketchDelta) -> Result<(), String> { + if self.alpha != delta.alpha { + return Err(format!( + "DDSketch delta: metadata mismatch: an alpha {} delta for an alpha {} sketch", + delta.alpha, self.alpha + )); + } + let count = delta + .deltas + .iter() + .chain(&delta.negative_deltas) + .chain([&delta.zero_count]) + .try_fold(self.count, |acc, &c| acc.checked_add(c)) + .ok_or_else(|| "DDSketch delta: the total sample count overflows".to_string())?; + if !grows_within_i32(&self.store, &delta.indices) + || !grows_within_i32(&self.negative_store, &delta.negative_indices) + { + return Err("DDSketch delta: a store would span past i32".to_string()); + } + let stores = [ + (false, &delta.indices, &delta.deltas), + (true, &delta.negative_indices, &delta.negative_deltas), + ]; + for (negative, indices, deltas) in stores { + for (&k, &c) in indices.iter().zip(deltas) { + let store = if negative { + &mut self.negative_store + } else { + &mut self.store + }; + store.ensure(k); + store.counts.as_mut_slice()[(k - store.offset) as usize] += c; + let magnitude = self.bin_representative(k); + let r = if negative { -magnitude } else { magnitude }; + self.add_to_sum(r * c as f64); + self.min = self.min.min(r); + self.max = self.max.max(r); + } + } + if delta.zero_count != 0 { + self.zero_count += delta.zero_count; + self.min = self.min.min(0.0); + self.max = self.max.max(0.0); + } + self.count = count; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::common::structures::Vector1D; + use crate::sketches::ddsketch::DDSketchState; + + const ALPHA: f64 = 0.01; + /// `key(min_v)` and `key(max_v)` of §3.23 rule 5 at `ALPHA`. + const LOWEST: i32 = -35418; + const HIGHEST: i32 = 35448; + + type Cells<'a> = (&'a [i32], &'a [u64]); + + /// An `ALPHA` delta envelope around the given payload arrays. + fn delta_bytes(positive: Cells, negative: Cells, zero_count: u64) -> Vec { + let metadata = encode_metadata(ALPHA).unwrap(); + let payload = + rmp_serde::to_vec(&(positive.0, positive.1, negative.0, negative.1, zero_count)) + .unwrap(); + envelope::encode(DD_DELTA_KIND, &metadata, &payload) + } + + #[test] + fn decode_rejects_each_payload_rule() { + let bounds: Cells = (&[LOWEST, HIGHEST], &[1, 1]); + assert!(DDSketchDelta::deserialize_from_bytes(&delta_bytes(bounds, bounds, 0)).is_ok()); + let none: Cells = (&[], &[]); + let rows: [(&str, Cells, Cells); 3] = [ + ("index below key(min_v)", (&[LOWEST - 1], &[1]), none), + ("index above key(max_v)", (&[HIGHEST + 1], &[1]), none), + ( + "negative index above key(max_v)", + none, + (&[HIGHEST + 1], &[1]), + ), + ]; + for (label, positive, negative) in rows { + assert!( + DDSketchDelta::deserialize_from_bytes(&delta_bytes(positive, negative, 0)).is_err(), + "{label}" + ); + } + } + + #[test] + fn decode_rejects_another_kind_and_new_another_alpha() { + let mut bytes = delta_bytes((&[0], &[1]), (&[], &[]), 0); + assert!(DDSketchDelta::deserialize_from_bytes(&bytes).is_ok()); + bytes[9] = 0x00; + assert!(DDSketchDelta::deserialize_from_bytes(&bytes).is_err()); + assert!(DDSketchDelta::new(1.0, vec![], vec![], vec![], vec![], 1).is_err()); + } + + /// A delta onto an empty sketch moves `min` / `max` to the representatives + /// it adds, and its zero count folds in `0.0`. + #[test] + fn merge_delta_folds_the_scalars() { + let mut sketch = DDSketch::new(ALPHA); + let delta = DDSketchDelta::new(ALPHA, vec![10], vec![2], vec![], vec![], 0).unwrap(); + sketch.merge_delta(&delta).unwrap(); + let r = (1.0 + ALPHA) * ((1.0 + ALPHA) / (1.0 - ALPHA)).powf(10.0); + assert_eq!( + (sketch.min(), sketch.max(), sketch.sum()), + (Some(r), Some(r), 2.0 * r) + ); + + let zeros = DDSketchDelta::new(ALPHA, vec![], vec![], vec![], vec![], 3).unwrap(); + sketch.merge_delta(&zeros).unwrap(); + assert_eq!( + (sketch.min(), sketch.zero_count(), sketch.get_count()), + (Some(0.0), 3, 5) + ); + } + + #[test] + fn merge_delta_rejects_without_changing_the_state() { + let mut sketch = DDSketch::new(ALPHA); + sketch.add(&3.0f64); + let next_alpha = f64::from_bits(ALPHA.to_bits() + 1); + let other = DDSketchDelta::new(next_alpha, vec![0], vec![1], vec![], vec![], 0).unwrap(); + assert!(sketch.merge_delta(&other).is_err()); + assert_eq!(sketch.get_count(), 1); + + sketch.store.counts = Vector1D::from_vec(vec![u64::MAX - 1]); + sketch.store.offset = 55; + sketch.count = u64::MAX - 1; + let before = sketch.serialize_to_bytes().unwrap(); + let rows = [ + DDSketchDelta::new(ALPHA, vec![0], vec![1], vec![5], vec![1], 0), + DDSketchDelta::new(ALPHA, vec![], vec![], vec![], vec![], 2), + ]; + for delta in rows { + assert!(sketch.merge_delta(&delta.unwrap()).is_err()); + assert_eq!(sketch.get_count(), u64::MAX - 1); + assert_eq!(sketch.serialize_to_bytes().unwrap(), before); + } + } + + /// A store that would grow past `i32` is refused, unchanged, instead of + /// overflowing the index arithmetic. + #[test] + fn merge_delta_rejects_a_store_span_past_i32() { + let mut sketch = DDSketch::try_from(DDSketchState { + alpha: ALPHA, + store: Buckets { + counts: Vector1D::from_vec(vec![1]), + offset: i32::MAX - 1, + }, + sum: 1e300, + min: 1e300, + max: 1e300, + negative_store: Buckets::new(), + zero_count: 0, + }) + .unwrap(); + let before = sketch.serialize_to_bytes().unwrap(); + let delta = DDSketchDelta::new(ALPHA, vec![-10], vec![1], vec![], vec![], 0).unwrap(); + assert!(sketch.merge_delta(&delta).is_err()); + assert_eq!(sketch.serialize_to_bytes().unwrap(), before); + } +} diff --git a/src/sketches/ddsketch/wire.rs b/src/sketches/ddsketch/wire.rs index 5259aed6..fc90c97c 100644 --- a/src/sketches/ddsketch/wire.rs +++ b/src/sketches/ddsketch/wire.rs @@ -6,7 +6,7 @@ //! algorithm lives in the parent module file. Being a descendant module, it //! reads the sketch's private `store` / `count` / `sum` / `min` / `max` fields //! directly without widening any field visibility. See -//! `docs/asapv1_wire_format.md` §3. +//! `docs/asapv1_wire_format.md` §3.8. //! //! ## DDSketch metadata has no hash spec //! @@ -23,9 +23,8 @@ //! //! ## Signed stores //! -//! Positive-only payloads retain metadata version 1. Signed or zero-containing -//! payloads use version 2 and append a negative-magnitude store and zero count. -//! Older native readers reject version 2 instead of silently dropping samples. +//! Every payload carries the positive store, the negative-magnitude store and +//! the zero count, whether or not the sketch holds a value `<= 0`. use rmp_serde::{decode::Error as RmpDecodeError, encode::Error as RmpEncodeError}; use serde::{Deserialize, Serialize}; @@ -34,7 +33,7 @@ use crate::common::structures::Vector1D; use crate::message_pack_format::envelope; use crate::message_pack_format::wire_key::exact; -use super::{Buckets, DDSketch}; +use super::{Buckets, DDSketch, DDSketchState}; /// DDSketch kind_id: family `0x05`, single algorithm variant `0x00`. const DD_KIND: &[u8] = &[0x05, 0x00]; @@ -60,28 +59,34 @@ fn dd_metadata(alpha: f64) -> DdMetadata { } } -/// DDSketch payload (ASAPv1 §3), a msgpack **array** (`to_vec`, positional): -/// `[counts, offset, sum, min, max]`. `counts` is the dense bucket store, -/// carried verbatim including the growth padding, so a decoded sketch -/// re-serializes byte-identically; the bucket index of `counts[i]` is -/// `offset + i`. `sum` / `min` / `max` are the sketch's running scalars, which -/// the bucket counts do not determine: exact over the values passed to `add`, -/// and the bucket representative for a bucket promoted through `apply_delta`. -/// The total sample count is the sum of `counts` and so is not carried. -#[derive(Debug, Serialize, Deserialize)] -struct DdPayload { - counts: Vec, - offset: i32, - #[serde(deserialize_with = "exact")] - sum: f64, - #[serde(deserialize_with = "exact")] - min: f64, - #[serde(deserialize_with = "exact")] - max: f64, +/// Encodes the metadata block for `alpha`. +pub(super) fn encode_metadata(alpha: f64) -> Result, RmpEncodeError> { + rmp_serde::to_vec_named(&dd_metadata(alpha)) +} + +/// Decodes the metadata block to its `alpha`: exactly `metadata_version` 1 and +/// an `alpha` inside `(0, 1)`. +pub(super) fn decode_metadata(metadata: &[u8]) -> Result { + let meta: DdMetadata = envelope::decode_block(metadata)?; + // `alpha` is a property of the stored sketch rather than of the target + // type, so it is echoed back into the expected block instead of pinned. + if meta != dd_metadata(meta.alpha) { + return Err(RmpDecodeError::Uncategorized( + "ASAPv1 DDSketch envelope: metadata mismatch".to_string(), + )); + } + check_alpha(meta.alpha).map_err(RmpDecodeError::Uncategorized)?; + Ok(meta.alpha) } +/// DDSketch payload (ASAPv1 §3.8), a msgpack **array** (`to_vec`, positional). +/// Each store is carried verbatim including the growth padding, so a decoded +/// sketch re-serializes byte-identically; the bucket index of `counts[i]` is +/// `offset + i`. `sum` / `min` / `max` are the running scalars, which the +/// bucket counts do not determine. The total sample count is the sum of both +/// stores and `zero_count`, and so is not carried. #[derive(Debug, Serialize, Deserialize)] -struct SignedPayload { +struct DdPayload { counts: Vec, offset: i32, #[serde(deserialize_with = "exact")] @@ -95,17 +100,6 @@ struct SignedPayload { zero_count: u64, } -pub(super) fn check_signed_scalars(count: u64, sum: f64, min: f64, max: f64) -> Result<(), String> { - if count == 0 { - return check_scalars(count, sum, min, max); - } - if sum.is_finite() && min.is_finite() && max.is_finite() && min <= max { - Ok(()) - } else { - Err("DDSketch invalid signed scalars".into()) - } -} - /// Checks a relative accuracy against the domain [`DDSketch::new`] accepts: /// finite and strictly inside `(0, 1)`. Outside it the `gamma` derivation is /// non-positive or infinite and every bucket index is meaningless. @@ -119,7 +113,8 @@ pub(super) fn check_alpha(alpha: f64) -> Result<(), String> { /// Checks a bucket store's index span: an empty store sits at offset `0`, and /// a populated one's highest index `offset + len - 1` is representable as an /// `i32`. Applied before the store is rebuilt from the declared offset. -pub(super) fn check_store_span(offset: i32, len: usize) -> Result<(), String> { +fn check_store_span(store: &Buckets) -> Result<(), String> { + let (offset, len) = (store.offset, store.counts.len()); if len == 0 { if offset != 0 { return Err(format!( @@ -137,16 +132,26 @@ pub(super) fn check_store_span(offset: i32, len: usize) -> Result<(), String> { Ok(()) } -/// Total sample count, the checked sum of every bucket. `None` on overflow. -pub(super) fn total_count(counts: &[u64]) -> Option { - counts.iter().try_fold(0u64, |acc, &c| acc.checked_add(c)) +/// Sum of a store's bucket counts. `None` on overflow. +fn store_total(store: &Buckets) -> Option { + store + .counts + .as_slice() + .iter() + .try_fold(0u64, |acc, &c| acc.checked_add(c)) } -/// Checks the running scalars against the store's total count. An empty sketch -/// carries `0.0` / `+inf` / `-inf`, the state [`DDSketch::new`] starts in. A -/// populated one carries finite scalars with `0 < min <= max`, and `sum >= min` -/// since every ingested value is at least `min`. -pub(super) fn check_scalars(count: u64, sum: f64, min: f64, max: f64) -> Result<(), String> { +/// Checks the running scalars against the total count. An empty sketch carries +/// `0.0` / `+inf` / `-inf`, the state [`DDSketch::new`] starts in. A populated +/// one carries finite scalars with `min <= max`; when every value is positive, +/// also `0 < min` and `sum >= min`. +fn check_scalars( + count: u64, + positive_only: bool, + sum: f64, + min: f64, + max: f64, +) -> Result<(), String> { if count == 0 { if sum == 0.0 && min == f64::INFINITY && max == f64::NEG_INFINITY { return Ok(()); @@ -160,7 +165,7 @@ pub(super) fn check_scalars(count: u64, sum: f64, min: f64, max: f64) -> Result< "DDSketch scalars must be finite for a populated store: {sum}, {min}, {max}" )); } - if !(min > 0.0 && min <= max && sum >= min) { + if !(min <= max && (!positive_only || (min > 0.0 && sum >= min))) { return Err(format!( "DDSketch scalars out of order: sum={sum}, min={min}, max={max}" )); @@ -168,68 +173,62 @@ pub(super) fn check_scalars(count: u64, sum: f64, min: f64, max: f64) -> Result< Ok(()) } +/// Checks a state against §3.8 rules 3 through 7 and returns its total sample +/// count, the checked sum of both stores and `zero_count`. +pub(super) fn check_state(state: &DDSketchState) -> Result { + check_alpha(state.alpha)?; + check_store_span(&state.store)?; + check_store_span(&state.negative_store)?; + let overflow = || "DDSketch bucket counts overflow the total sample count".to_string(); + let negative = store_total(&state.negative_store).ok_or_else(overflow)?; + let count = store_total(&state.store) + .and_then(|c| c.checked_add(negative)) + .and_then(|c| c.checked_add(state.zero_count)) + .ok_or_else(overflow)?; + let positive_only = negative == 0 && state.zero_count == 0; + check_scalars(count, positive_only, state.sum, state.min, state.max)?; + Ok(count) +} + // Wire serialization for DDSketch. `wire` is a descendant of the sketch module, // so these impls read the private fields and construct the struct directly. impl DDSketch { /// Serializes the sketch into an ASAPv1 MessagePack envelope /// (kind_id `0x05 0x00`). `alpha` lands in the metadata; the payload is the - /// bucket store plus the three scalars the buckets do not determine. + /// two bucket stores, the zero count and the three scalars. /// /// A sketch whose state the decoder would refuse — an out-of-range `alpha`, - /// a store spanning past `i32`, or bucket counts that overflow `u64` — is an - /// error rather than bytes that would be refused on decode. + /// a store spanning past `i32`, counts that overflow `u64`, or inconsistent + /// scalars — is an error rather than bytes that would be refused on decode. pub fn serialize_to_bytes(&self) -> Result, RmpEncodeError> { - let fail = |e: String| RmpEncodeError::Syntax(format!("ASAPv1 DDSketch envelope: {e}")); - check_alpha(self.alpha).map_err(fail)?; - let counts = self.store.counts.as_slice(); - check_store_span(self.store.offset, counts.len()).map_err(fail)?; - let count = total_count(counts) - .ok_or_else(|| fail("bucket counts overflow the total sample count".to_string()))?; - if self.negative_store.is_empty() && self.zero_count == 0 { - check_scalars(count, self.sum, self.min, self.max).map_err(fail)?; - } - - if !self.negative_store.is_empty() || self.zero_count != 0 { - check_store_span(self.negative_store.offset, self.negative_store.counts.len()) - .map_err(fail)?; - let count = total_count(self.store.counts.as_slice()) - .and_then(|c| { - total_count(self.negative_store.counts.as_slice()) - .and_then(|n| c.checked_add(n)) - }) - .and_then(|c| c.checked_add(self.zero_count)) - .ok_or_else(|| fail("count overflow".into()))?; - check_signed_scalars(count, self.sum, self.min, self.max).map_err(fail)?; - let metadata = rmp_serde::to_vec_named(&DdMetadata { - metadata_version: 2, - alpha: self.alpha, - })?; - let payload = rmp_serde::to_vec(&SignedPayload { - counts: counts.to_vec(), - offset: self.store.offset, - sum: self.sum, - min: self.min, - max: self.max, - negative_counts: self.negative_store.counts.as_slice().to_vec(), - negative_offset: self.negative_store.offset, - zero_count: self.zero_count, - })?; - return Ok(envelope::encode(DD_KIND, &metadata, &payload)); - } - let metadata = rmp_serde::to_vec_named(&dd_metadata(self.alpha))?; + let state = DDSketchState { + alpha: self.alpha, + store: self.store.clone(), + sum: self.sum, + min: self.min, + max: self.max, + negative_store: self.negative_store.clone(), + zero_count: self.zero_count, + }; + check_state(&state) + .map_err(|e| RmpEncodeError::Syntax(format!("ASAPv1 DDSketch envelope: {e}")))?; + let metadata = encode_metadata(self.alpha)?; let payload = rmp_serde::to_vec(&DdPayload { - counts: counts.to_vec(), - offset: self.store.offset, + counts: state.store.counts.into_vec(), + offset: state.store.offset, sum: self.sum, min: self.min, max: self.max, + negative_counts: state.negative_store.counts.into_vec(), + negative_offset: state.negative_store.offset, + zero_count: self.zero_count, })?; Ok(envelope::encode(DD_KIND, &metadata, &payload)) } /// Deserializes a DDSketch from an ASAPv1 MessagePack envelope. The index /// mapping is rebuilt from the metadata `alpha` and the total sample count - /// is summed from the buckets. Every inconsistency fails closed. + /// is summed from the stores. Every inconsistency fails closed. pub fn deserialize_from_bytes(bytes: &[u8]) -> Result { let (kind_id, metadata, payload) = envelope::split(bytes).map_err(RmpDecodeError::Uncategorized)?; @@ -238,64 +237,24 @@ impl DDSketch { "DDSketch kind_id mismatch: stored {kind_id:?}, expected {DD_KIND:?}" ))); } - let meta: DdMetadata = envelope::decode_block(metadata)?; - // `alpha` is a property of the stored sketch rather than of the target - // type, so it is echoed back into the expected block and bounded by - // range instead of being pinned. - if meta.metadata_version == 2 { - let p: SignedPayload = envelope::decode_block(payload)?; - return DDSketch::try_from(super::DDSketchState { - alpha: meta.alpha, - store: Buckets { - counts: Vector1D::from_vec(p.counts), - offset: p.offset, - }, - negative_store: Buckets { - counts: Vector1D::from_vec(p.negative_counts), - offset: p.negative_offset, - }, - zero_count: p.zero_count, - sum: p.sum, - min: p.min, - max: p.max, - }) - .map_err(RmpDecodeError::Uncategorized); - } - if meta != dd_metadata(meta.alpha) { - return Err(RmpDecodeError::Uncategorized( - "ASAPv1 DDSketch envelope: metadata mismatch".to_string(), - )); - } - check_alpha(meta.alpha).map_err(RmpDecodeError::Uncategorized)?; - + let alpha = decode_metadata(metadata)?; let p: DdPayload = envelope::decode_block(payload)?; - check_store_span(p.offset, p.counts.len()).map_err(RmpDecodeError::Uncategorized)?; - let count = total_count(&p.counts).ok_or_else(|| { - RmpDecodeError::Uncategorized( - "DDSketch bucket counts overflow the total sample count".to_string(), - ) - })?; - check_scalars(count, p.sum, p.min, p.max).map_err(RmpDecodeError::Uncategorized)?; - - let alpha = meta.alpha; - let gamma = (1.0 + alpha) / (1.0 - alpha); - let log_gamma = gamma.ln(); - Ok(DDSketch { + DDSketch::try_from(DDSketchState { alpha, - gamma, - log_gamma, - inv_log_gamma: 1.0 / log_gamma, store: Buckets { counts: Vector1D::from_vec(p.counts), offset: p.offset, }, - count, sum: p.sum, min: p.min, max: p.max, - negative_store: Buckets::new(), - zero_count: 0, + negative_store: Buckets { + counts: Vector1D::from_vec(p.negative_counts), + offset: p.negative_offset, + }, + zero_count: p.zero_count, }) + .map_err(RmpDecodeError::Uncategorized) } } @@ -314,22 +273,33 @@ mod tests { sketch } - /// Builds a real envelope with real metadata around a hand-built payload, - /// so the crafted-bytes tests exercise the decoder's own rules rather than - /// the envelope's framing. - fn crafted(alpha: f64, counts: Vec, offset: i32, sum: f64, min: f64, max: f64) -> Vec { - let metadata = rmp_serde::to_vec_named(&dd_metadata(alpha)).unwrap(); - let payload = rmp_serde::to_vec(&DdPayload { + /// A payload with only a positive store. + fn positive(counts: Vec, offset: i32, sum: f64, min: f64, max: f64) -> DdPayload { + DdPayload { counts, offset, sum, min, max, - }) - .unwrap(); + negative_counts: Vec::new(), + negative_offset: 0, + zero_count: 0, + } + } + + /// Builds a real envelope with real metadata around a hand-built payload, + /// so the crafted-bytes tests exercise the decoder's own rules rather than + /// the envelope's framing. + fn crafted_payload(alpha: f64, payload: &DdPayload) -> Vec { + let metadata = rmp_serde::to_vec_named(&dd_metadata(alpha)).unwrap(); + let payload = rmp_serde::to_vec(payload).unwrap(); envelope::encode(DD_KIND, &metadata, &payload) } + fn crafted(alpha: f64, counts: Vec, offset: i32, sum: f64, min: f64, max: f64) -> Vec { + crafted_payload(alpha, &positive(counts, offset, sum, min, max)) + } + /// The message a rejected decode fails with, so a test can pin *which* /// rule fired rather than settling for any error at all. fn decode_error(bytes: &[u8]) -> String { @@ -382,23 +352,24 @@ mod tests { assert_eq!(decoded.alpha(), sketch.alpha()); } - /// The total sample count is the sum of the buckets, so the payload carries - /// no `count` field: it is a 5-element array and nothing more. + /// A positive-only sketch still carries all eight payload fields, and the + /// total sample count is not one of them. #[test] - fn ddsketch_count_is_recovered_from_the_buckets() { + fn ddsketch_payload_is_eight_fields_without_a_count() { + type Eight = (Vec, i32, f64, f64, f64, Vec, i32, u64); let sketch = populated(); let bytes = sketch.serialize_to_bytes().expect("serialize"); let (_, _, payload) = envelope::split(&bytes).expect("split"); - let five: (Vec, i32, f64, f64, f64) = from_slice(payload).expect("5-element payload"); - assert_eq!(five.0.iter().sum::(), sketch.get_count()); - assert!( - from_slice::<(Vec, i32, f64, f64, f64, u64)>(payload).is_err(), - "the payload must not carry a sixth element" + let eight: Eight = envelope::decode_block(payload).expect("8-element payload"); + assert_eq!(eight.0.iter().sum::(), sketch.get_count()); + assert_eq!((eight.5.len(), eight.6, eight.7), (0, 0, 0)); + assert_eq!( + DDSketch::deserialize_from_bytes(&bytes) + .expect("decode") + .get_count(), + sketch.get_count() ); - - let decoded = DDSketch::deserialize_from_bytes(&bytes).expect("decode"); - assert_eq!(decoded.get_count(), sketch.get_count()); } #[test] @@ -447,16 +418,9 @@ mod tests { "Count-Min bytes must not decode as a DDSketch" ); - let metadata = rmp_serde::to_vec_named(&dd_metadata(ALPHA)).unwrap(); - let payload = rmp_serde::to_vec(&DdPayload { - counts: Vec::new(), - offset: 0, - sum: 0.0, - min: f64::INFINITY, - max: f64::NEG_INFINITY, - }) - .unwrap(); - let bytes = envelope::encode(&[0x02, 0x00], &metadata, &payload); + let mut bytes = DDSketch::new(ALPHA).serialize_to_bytes().unwrap(); + assert!(DDSketch::deserialize_from_bytes(&bytes).is_ok()); + bytes[8] = 0x02; assert!( DDSketch::deserialize_from_bytes(&bytes).is_err(), "a foreign kind_id must be rejected" @@ -502,6 +466,24 @@ mod tests { ); } + /// `metadata_version` is `1`; any other version is refused. + #[test] + fn ddsketch_rejects_another_metadata_version() { + let payload = rmp_serde::to_vec(&positive(vec![3], 7, 6.0, 1.5, 2.5)).unwrap(); + for version in [0u8, 2] { + let metadata = rmp_serde::to_vec_named(&DdMetadata { + metadata_version: version, + alpha: ALPHA, + }) + .unwrap(); + let err = decode_error(&envelope::encode(DD_KIND, &metadata, &payload)); + assert!( + err.contains("metadata mismatch"), + "version {version}: {err}" + ); + } + } + /// `alpha` outside `(0, 1)` makes every bucket index meaningless, so it is /// rejected rather than used to derive a nonsense mapping. #[test] @@ -520,6 +502,15 @@ mod tests { ); } + /// `alpha` is checked before the payload is read: a bad `alpha` in front of + /// an unreadable payload fails on `alpha`. + #[test] + fn ddsketch_checks_alpha_before_the_payload() { + let metadata = rmp_serde::to_vec_named(&dd_metadata(2.0)).unwrap(); + let err = decode_error(&envelope::encode(DD_KIND, &metadata, &[0xc1])); + assert!(err.contains("alpha"), "got {err}"); + } + /// A store whose declared offset pushes its highest bucket index past /// `i32` is rejected from the offset and the array length alone, before the /// store is rebuilt — and never panics. @@ -530,6 +521,11 @@ mod tests { err.contains("store span past i32"), "an overflowing store span must be rejected as such, got {err}" ); + let mut negative = positive(vec![3], 7, 6.0, -1.0, 2.5); + negative.negative_counts = vec![1; 10]; + negative.negative_offset = i32::MAX - 2; + let err = decode_error(&crafted_payload(ALPHA, &negative)); + assert!(err.contains("store span past i32"), "negative store: {err}"); // The same payload one bucket shorter than the boundary decodes. let ok = crafted(ALPHA, vec![1; 3], i32::MAX - 2, 5.0, 1.0, 2.0); assert!(DDSketch::deserialize_from_bytes(&ok).is_ok()); @@ -540,29 +536,35 @@ mod tests { /// different bytes. #[test] fn ddsketch_rejects_a_nonzero_offset_on_an_empty_store() { - let err = decode_error(&crafted( - ALPHA, - Vec::new(), - 42, - 0.0, - f64::INFINITY, - f64::NEG_INFINITY, - )); + let empty = || positive(Vec::new(), 0, 0.0, f64::INFINITY, f64::NEG_INFINITY); + let mut payload = empty(); + payload.offset = 42; + let err = decode_error(&crafted_payload(ALPHA, &payload)); assert!( err.contains("empty store must be at offset 0"), "a non-zero empty-store offset must be rejected as such, got {err}" ); } - /// Bucket counts that overflow the recovered total are rejected rather than - /// wrapping into a count the quantile walk would disagree with. + /// Counts that overflow the recovered total — across both stores and the + /// zero count — are rejected rather than wrapping. #[test] fn ddsketch_rejects_a_total_count_overflow() { - let err = decode_error(&crafted(ALPHA, vec![u64::MAX, 1], 0, 5.0, 1.0, 2.0)); - assert!( - err.contains("overflow the total sample count"), - "an overflowing bucket total must be rejected as such, got {err}" - ); + let mut negative = positive(vec![u64::MAX], 0, 5.0, -1.0, 2.0); + negative.negative_counts = vec![1]; + let mut zero = positive(vec![u64::MAX], 0, 5.0, 0.0, 2.0); + zero.zero_count = 1; + for payload in [ + positive(vec![u64::MAX, 1], 0, 5.0, 1.0, 2.0), + negative, + zero, + ] { + let err = decode_error(&crafted_payload(ALPHA, &payload)); + assert!( + err.contains("overflow the total sample count"), + "an overflowing total must be rejected as such, got {err}" + ); + } } /// The scalars the buckets do not determine are still bounded by them: an @@ -595,6 +597,28 @@ mod tests { } } + /// `0 < min` and `sum >= min` hold only while the negative store sums to + /// zero and `zero_count` is zero; an all-zero negative store keeps them. + #[test] + fn ddsketch_positive_scalar_checks_follow_the_counts() { + let mut zero_negative = positive(vec![3], 7, 6.0, -1.0, 2.5); + zero_negative.negative_counts = vec![0, 0]; + zero_negative.negative_offset = 5; + let err = decode_error(&crafted_payload(ALPHA, &zero_negative)); + assert!(err.contains("scalars out of order"), "got {err}"); + + let mut negative = positive(vec![3], 7, -0.5, -1.5, 2.5); + negative.negative_counts = vec![1]; + negative.negative_offset = 5; + let mut zero = positive(vec![3], 7, 0.0, 0.0, 2.5); + zero.zero_count = 2; + for payload in [negative, zero] { + let bytes = crafted_payload(ALPHA, &payload); + let decoded = DDSketch::deserialize_from_bytes(&bytes).expect("decode"); + assert_eq!(decoded.serialize_to_bytes().expect("re-serialize"), bytes); + } + } + /// A sketch whose store spans past `i32` would be refused on decode, so it /// must not serialize either. #[test] diff --git a/src/sketches/mod.rs b/src/sketches/mod.rs index 780864fe..ad3484d4 100644 --- a/src/sketches/mod.rs +++ b/src/sketches/mod.rs @@ -104,7 +104,7 @@ pub mod uniform; pub use uniform::UniformSampling; pub mod ddsketch; -pub use ddsketch::DDSketch; +pub use ddsketch::{DDSketch, DDSketchDelta}; pub mod countminsketch_topk; pub use countminsketch_topk::{CMSHeap, CMSHeapDelta}; diff --git a/tests/asapv1_golden.rs b/tests/asapv1_golden.rs index 300916b3..5ce177d7 100644 --- a/tests/asapv1_golden.rs +++ b/tests/asapv1_golden.rs @@ -13,10 +13,10 @@ use std::collections::HashSet; use asap_sketchlib::sketches::CountSketchDelta; use asap_sketchlib::sketches::hll::HllWireVariant; use asap_sketchlib::{ - CMSHeap, CMSHeapDelta, CSHeap, CSHeapDelta, Classic, Count, CountMin, CountMinDelta, - DeltaResult, ErtlMLE, FastPath, HHHeap, HeapCount, HeapItem, HllBucketListP14, HllSketch, - HllVariant, HyperLogLogDelta, HyperLogLogHIPP12, HyperLogLogP12, HyperLogLogP14, KLL, - MessagePackCodec, RegularPath, SetAggregator, Vector2D, peek_kind_id, + CMSHeap, CMSHeapDelta, CSHeap, CSHeapDelta, Classic, Count, CountMin, CountMinDelta, DDSketch, + DDSketchDelta, DeltaResult, ErtlMLE, FastPath, HHHeap, HeapCount, HeapItem, HllBucketListP14, + HllSketch, HllVariant, HyperLogLogDelta, HyperLogLogHIPP12, HyperLogLogP12, HyperLogLogP14, + KLL, MessagePackCodec, RegularPath, SetAggregator, Vector2D, peek_kind_id, }; fn decode_hex(s: &str) -> Vec { @@ -60,6 +60,10 @@ const GOLDEN_CMSHEAP_DELTA: &str = const GOLDEN_CSHEAP_STR: &str = include_str!("../asapv1_golden/csheap_i64_regular_2x4_strkeys.hex"); const GOLDEN_CSHEAP_F64: &str = include_str!("../asapv1_golden/csheap_f64_fast_2x4_strkeys.hex"); const GOLDEN_CSHEAP_DELTA: &str = include_str!("../asapv1_golden/csheap_i64_regular_2x4_delta.hex"); +const GOLDEN_DD_POSITIVE: &str = include_str!("../asapv1_golden/ddsketch_positive_a001.hex"); +const GOLDEN_DD_SIGNED: &str = include_str!("../asapv1_golden/ddsketch_signed_a001.hex"); +const GOLDEN_DD_EMPTY: &str = include_str!("../asapv1_golden/ddsketch_empty_a001.hex"); +const GOLDEN_DD_DELTA: &str = include_str!("../asapv1_golden/ddsketch_signed_a001_delta.hex"); const GOLDEN_KLL_F64: &str = include_str!("../asapv1_golden/kll_f64_k200.hex"); const GOLDEN_KLL_I64: &str = include_str!("../asapv1_golden/kll_i64_k200.hex"); const GOLDEN_SET_STRINGS: &str = include_str!("../asapv1_golden/set_aggregator_strings.hex"); @@ -684,6 +688,135 @@ fn kll_i64_k200_matches_golden() { assert_eq!(decoded.quantile(1.0), 50.0); } +// --------------------------------------------------------------------------- +// DDSketch and its delta: set the stores and scalars directly -> serialize == +// golden, golden round-trips, and the delta merged into the signed state gives +// the expected stores and scalars. +// --------------------------------------------------------------------------- + +const DD_ALPHA: f64 = 0.01; + +/// An α=0.01 DDSketch with its stores, zero count and scalars set directly, +/// built through `DDSketch`'s serde form. +fn dd_state( + positive: (&[u64], i32), + negative: (&[u64], i32), + zero_count: u64, + scalars: (f64, f64, f64), +) -> DDSketch { + let (sum, min, max) = scalars; + let store = |(counts, offset): (&[u64], i32)| ((counts.to_vec(),), offset); + let state = ( + DD_ALPHA, + store(positive), + sum, + min, + max, + store(negative), + zero_count, + ); + rmp_serde::from_slice(&rmp_serde::to_vec(&state).unwrap()).expect("valid state") +} + +fn assert_dd_golden(golden: &str, sketch: &DDSketch) { + let want = decode_hex(golden); + assert_eq!(sketch.serialize_to_bytes().expect("serialize"), want); + let decoded = DDSketch::deserialize_from_bytes(&want).expect("decode"); + assert_eq!(decoded.serialize_to_bytes().expect("re-serialize"), want); +} + +/// A store's non-zero buckets as `(index, count)`. +fn dd_buckets(counts: &[u64], offset: i32) -> Vec<(i32, u64)> { + (offset..) + .zip(counts.iter().copied()) + .filter(|&(_, c)| c != 0) + .collect() +} + +#[test] +fn ddsketch_positive_a001_matches_golden() { + let counts = [1, 0, 127, 128, 300, 65536, 4294967296]; + let scalars = (2181071000.0, 0.453125, 0.5078125); + let sketch = dd_state((&counts, -40), (&[], 0), 0, scalars); + assert_dd_golden(GOLDEN_DD_POSITIVE, &sketch); +} + +#[test] +fn ddsketch_signed_a001_matches_golden() { + let scalars = (2523.90625, -0.016, 515.0); + let sketch = dd_state((&[3, 0, 2], 310), (&[5, 1], -208), 7, scalars); + assert_dd_golden(GOLDEN_DD_SIGNED, &sketch); +} + +#[test] +fn ddsketch_empty_a001_matches_golden() { + assert_dd_golden(GOLDEN_DD_EMPTY, &DDSketch::new(DD_ALPHA)); +} + +#[test] +fn ddsketch_signed_a001_delta_matches_golden() { + let want = decode_hex(GOLDEN_DD_DELTA); + let indices = vec![-300, -100, -5, 7, 200, 400]; + let deltas = vec![1, 127, 128, 256, 65536, 4294967296]; + let negative_indices = vec![-208, -33, -1, 0, 130, 1000]; + let negative_deltas = vec![2, 3, 3, 1, 3, 1]; + let delta = DDSketchDelta::new( + DD_ALPHA, + indices.clone(), + deltas.clone(), + negative_indices.clone(), + negative_deltas.clone(), + 9, + ) + .expect("valid"); + assert_eq!(delta.serialize_to_bytes().expect("serialize"), want); + + let decoded = DDSketchDelta::deserialize_from_bytes(&want).expect("decode"); + assert_eq!(decoded.serialize_to_bytes().expect("re-serialize"), want); + + let mut state = DDSketch::deserialize_from_bytes(&decode_hex(GOLDEN_DD_SIGNED)).expect("state"); + state.merge_delta(&decoded).expect("merge"); + + let gamma = (1.0 + DD_ALPHA) / (1.0 - DD_ALPHA); + let r = |k: i32| (1.0 + DD_ALPHA) * gamma.powf(f64::from(k)); + let mut sum = 2523.90625; + for (k, c) in indices.iter().zip(&deltas) { + sum += r(*k) * *c as f64; + } + for (k, c) in negative_indices.iter().zip(&negative_deltas) { + sum += -r(*k) * *c as f64; + } + assert_eq!( + dd_buckets(state.store_counts(), state.store_offset()), + [ + (-300, 1), + (-100, 127), + (-5, 128), + (7, 256), + (200, 65536), + (310, 3), + (312, 2), + (400, 4294967296) + ] + ); + assert_eq!( + dd_buckets(state.negative_store_counts(), state.negative_store_offset()), + [ + (-208, 7), + (-207, 1), + (-33, 3), + (-1, 3), + (0, 1), + (130, 3), + (1000, 1) + ] + ); + assert_eq!((state.zero_count(), state.get_count()), (16, 4295033384)); + assert_eq!(state.sum(), sum); + assert_eq!(state.min(), Some(-r(1000))); + assert_eq!(state.max(), Some(r(400))); +} + // --------------------------------------------------------------------------- // SetAggregator and its delta: set the members directly -> serialize == // golden, and golden round-trips.