diff --git a/CHANGELOG.md b/CHANGELOG.md index 863f6352..073f0f82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,32 @@ signals a backwards-compatible change. ### Added +- **Sketch analytics** (`asap_sketchlib::analytics`): the + `SketchAnalytics` trait, with `ErrorBound`, `ErrorMetric` and `NormScale` + re-exported at the root, gives a sketch type's theoretical memory, update, + query and merge ops, error bound per statistic, and the parameters that + meet a target `(ε, δ)`, as `docs/sketch_analytics.md` defines them. +- **`StatisticKind::QuantileInterpolated`**, answered by `DDSketch`. +- **`memory_bytes()`** on `CountMinHll`, `MicroCm`, `SpaceSaving`, + `ElasticSketch`, `CocoSketch`, `CountMinSketchWithSpaceSaving`, + `CountSketchWithSpaceSaving`, `CountMinSketchFold`, `CountSketchFold`, + `SetAggregator`, `Bloom`, `UniformSampling`, `UnivMonTwoTier`, `Hydra`, + `HydraCounter`, `ExponentialHistogram`, `EhBucketSketch`, + `ExponentialHistogramUnivMon`, `UnivMonPool`, `NitroBatch` over a + Count-Min or Count Sketch, `TumblingWindow`, `HashSketchEnsemble` and + `EnsembleSketch`, and `SpaceSaving::theoretical_memory_bytes`, + `Bloom::theoretical_memory_bytes(rows, cols)`, + `UnivMonTwoTier::theoretical_memory_bytes` and + `UnivMonQ::theoretical_memory_bytes(config)`, which also bounds a cloned + or decoded `UnivMonQ`. +- **`SetAggregator::contains`**: whether a key is in the set. +- **`TumblingWindowSketch::tumbling_memory_bytes` and `tumbling_cells`**, + provided methods `TumblingWindow::memory_bytes` and the `op-count` + feature read; the built-in window sketches override both. +- **`op-count` feature**: `analytics::ops::count(f)` returns the ops `f` + performed on this thread, one per counter, register, bucket or item read or + written, for testing the analytics op formulas. Off by default, and free + when off. - **Family-capability table** (`asap_sketchlib::capability`, re-exported at the root): `SketchAlgorithm::capability()` gives, for each public sketch family, the `StatisticKind`s one state answers, whether states merge and @@ -31,20 +57,20 @@ signals a backwards-compatible change. outside `[0, 1]`. Within `alpha` relative when both neighbours are indexable and share a sign; an absolute bound across zero. The legacy portable `DdSketch` had it before 0.4.0. -- **`estimated_memory_bytes(rows, cols)`** on `CountMinSketch` and +- **`theoretical_memory_bytes(rows, cols)`** on `CountMinSketch` and `CountSketch`: the `memory_bytes` of a sketch with those dimensions, the counter width taken from the type. -- **`Kll::estimated_memory_bytes(k, m, n)`**: an upper bound on the bytes a +- **`Kll::theoretical_memory_bytes(k, m, n)`**: an upper bound on the bytes a sketch of `k` and `m` holds after `n` items, merges and a cached quantile. -- **`DdSketch::memory_bytes`**, and **`DdSketch::estimated_memory_bytes(alpha, +- **`DdSketch::memory_bytes`**, and **`DdSketch::theoretical_memory_bytes(alpha, n, min_magnitude, max_magnitude, negatives)`**: an upper bound on the bytes after `n` values in that magnitude range, in any order and through merges. -- **`memory_bytes` and `estimated_memory_bytes(precision)`** on +- **`memory_bytes` and `theoretical_memory_bytes(precision)`** on `HyperLogLogImpl` and `HyperLogLogHipImpl`: the struct plus `2^precision` register bytes, the precision taken at run time. -- **`Kmv::estimated_memory_bytes(k, distinct)`**: the bytes a sketch of `k` +- **`Kmv::theoretical_memory_bytes(k, distinct)`**: the bytes a sketch of `k` holds after `distinct` distinct inputs, an upper bound for fewer. -- **`memory_bytes` and `estimated_memory_bytes`** on `CountSketchWithL2Norm` +- **`memory_bytes` and `theoretical_memory_bytes`** on `CountSketchWithL2Norm` (`rows, cols`) and `UnivMon` (`heap_size, rows, cols, layers, key`): the pyramid's estimate is the steady state once every layer's heap has churned. - **`CountMinSketch::total()`**: the total admitted update weight, row 0's @@ -53,6 +79,12 @@ signals a backwards-compatible change. estimates, by estimate descending, ties by flow id. ### Changed +- **Breaking: `CountMinFastOps` and `CountSketchFastOps` require + `memory_bytes`**, which `HashSketchEnsemble::memory_bytes` reads. +- **`estimated_memory_bytes` is renamed `theoretical_memory_bytes`** on + every sketch that has it (`CountMinSketch`, `CountSketch`, + `CountSketchWithL2Norm`, the WithHeap sketches, `TopKHeap`, `HyperLogLog`, + `HyperLogLogHip`, `Kmv`, `Kll`, `DdSketch`, `UnivMon`). Breaking. - **`Kmv` builds without the `experimental` feature**, with its ASAPv1 codec. - **Breaking: `DdSketch` no longer keeps `sum`, `min` or `max`.** The state is the two bucket stores and the zero count. `sum()`, `min()` and `max()` are @@ -92,7 +124,7 @@ signals a backwards-compatible change. saturated and then decreased now reports its true `l2` instead of an understatement; `l2`, `l2_squared`, UnivMon's `estimate_l2` and its terminal-mode heavy-hitter threshold change only in that case. Each row's - accumulator is 8 bytes wider (`memory_bytes`, `estimated_memory_bytes`). + accumulator is 8 bytes wider (`memory_bytes`, `theoretical_memory_bytes`). `update_weighted_with_hash_without_l2` and `as_storage_mut`, the two ways to move counters without the accumulator, are removed; use `update_weighted_with_hash`. @@ -123,6 +155,9 @@ signals a backwards-compatible change. ### Docs +- **UnivMon's distinct-count bound is `1.35/(kε²)`**, the variance the + proof derives, in place of the looser `2/(kε²)`, in the UnivMon and + UnivMonTwoTier API pages; the tests check the tighter bound. - **A receiver of `f64` deltas matches the producer only up to rounding.** For Count-Min, Count Sketch and the two heap kinds, the wire format (§3.23), the `DeltaCounter` docs and the API pages now state the bound; @@ -130,23 +165,35 @@ signals a backwards-compatible change. ### Fixed +- **Breaking: `CountMinSketchFold::new` and `CountSketchFold::new` panic + for `full_cols` above 65536** (`MAX_FULL_COLS`). A full column is stored as + `u16`, and a wider sketch folded distinct columns together. +- **`CountMinSketch` Total is `AsIfBuiltOverUnion` in the capability + table**, not `Exact`: with Nitro sampling enabled it is an unbiased + estimate. +- **Breaking: `Bloom::dimensions_for` meets its target.** It returns + `Option<(usize, usize)>`, widens the slices until the exact rate + `(1 - (1 - 1/cols)^n)^rows` is at most the target, and returns `None` when + that needs more than `BLOOM_MAX_BITS`, where it used to return the widest + slices that fit. `with_capacity` panics there. `predicted_fpp` uses the + exact rate in place of `(1 - e^(-n/cols))^rows`. - **`Kll::update` regains most of its pre-0.4.0 speed.** With the lazily grown buffer, `compress_while_updating` was inlined into the insert path, which then no longer inlined into callers; a stream of updates ran about 10% slower. Compaction is kept out of line again: within 4% of 0.3.x on a loaded host, at parity on a quiet one. -- **`TopKHeap::estimated_memory_bytes` and the WithHeap estimates saturate** +- **`TopKHeap::theoretical_memory_bytes` and the WithHeap estimates saturate** at `usize::MAX` for shapes past the address space instead of overflowing. - **Decoded top-k heaps reserve as built ones do.** The ASAPv1 state decoders of `CountMinSketchWithHeap`, `CountSketchWithHeap`, `UnivMon`, `UnivMonTwoTier` and `Kmv` give the heap the slots a built heap with the same entries holds, so a decoded sketch grows within - `estimated_memory_bytes`. Delta decoders still reserve for their entries + `theoretical_memory_bytes`. Delta decoders still reserve for their entries alone. - **Clones and applied deltas keep the heap's capacity.** `CommonHeap` and `TopKHeap` clones keep their source's slots and index buckets, and `merge_delta` on the WithHeap sketches refills the sketch's own heap, so - both grow within `estimated_memory_bytes`. + both grow within `theoretical_memory_bytes`. ### Added - **Checked f64 updates and heap rescoring for the Count-Min and Count Sketch diff --git a/Cargo.toml b/Cargo.toml index bfeff287..41412588 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,8 @@ proptest = "1.11.0" default = [] experimental = [] octo-runtime = ["core_affinity", "crossbeam-channel"] +# Counts the ops each call performs, for the analytics op-count tests. +op-count = [] [lib] crate-type = ["rlib"] diff --git a/README.md b/README.md index d4dca816..d33be7f8 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,7 @@ cargo test --all-features - `--all-features` enables every Cargo feature, so all feature-gated code is compiled and tested. The features include: - `experimental` — enables sketches and APIs that are still under development and may change without notice. - `octo-runtime` — enables the Octo multi-threaded runtime (pulls in `core_affinity` and `crossbeam-channel`). + - `op-count` — counts the ops each call performs, for testing the [sketch analytics](docs/sketch_analytics.md) op formulas. To build or test with a specific feature: diff --git a/docs/api/api_bloom.md b/docs/api/api_bloom.md index 362ef06b..671acea9 100644 --- a/docs/api/api_bloom.md +++ b/docs/api/api_bloom.md @@ -22,7 +22,7 @@ AND. ```rust fn with_capacity(expected_items: usize, target_fpp: f64) -> Self fn with_dimensions(rows: usize, cols: usize) -> Self -fn dimensions_for(expected_items: usize, target_fpp: f64) -> (usize, usize) +fn dimensions_for(expected_items: usize, target_fpp: f64) -> Option<(usize, usize)> const BLOOM_MAX_SLICES: usize // MATRIX_MAX_ROWS (20), the most rows ASAPv1 carries const BLOOM_MAX_BITS: usize // 2^31, the sizing ceiling @@ -30,10 +30,10 @@ const BLOOM_MAX_BITS: usize // 2^31, the sizing ceiling `with_capacity` takes `k = round(log2(1/p))` slices, **capped at `BLOOM_MAX_SLICES`**, then solves for the slice width that hits `p` with -exactly that many slices — `cols = -n / ln(1 - p^(1/k))` — and rounds it up to a -power of two. The rounding keeps the column fold free of modulo bias, so the -delivered rate lands under the target rather than over it. `dimensions_for` -reports the choice without allocating. +exactly that many slices — `cols = -n / ln(1 - p^(1/k))` — rounds it up to a +power of two, and doubles it until the exact rate `(1 - (1 - 1/cols)^n)^k` is at +most `p`. The power-of-two width keeps the column fold free of modulo bias. +`dimensions_for` reports the choice without allocating. The cap is the reason the width is solved for `k` rather than taken as the `k`-optimal split `m/k`. A filter has at most 20 slices, the most an ASAPv1 @@ -42,9 +42,9 @@ bits — about 20% more at `p = 1e-12` — and delivers the target, where the `k`-optimal split would have delivered `2^-20` no matter what was asked for. `with_capacity` and `dimensions_for` **panic on a NaN or infinite -`target_fpp`**; a finite value outside `(0, 1)` is clamped into it. A target -needing more than `BLOOM_MAX_BITS` gets the widest slices that fit, and -`predicted_fpp` then reports the rate those slices deliver. +`target_fpp`**; a finite value outside `(0, 1)` is clamped into it. For a target +needing more than `BLOOM_MAX_BITS`, `dimensions_for` returns `None` and +`with_capacity` panics. `Default` is 7 x 65536 — 56 KiB of packed bits — which holds 20k distinct keys at about 1 false positive in 11,500. @@ -83,7 +83,7 @@ fn size_in_bytes(&self) -> usize fn is_empty(&self) -> bool ``` -`predicted_fpp` is the model, `(1 - e^(-n/cols))^rows`. `estimated_fpp` reads +`predicted_fpp` is the model, `(1 - (1 - 1/cols)^n)^rows`. `estimated_fpp` reads the bits actually set, so it is the one to trust on a filter whose distinct count is unknown. diff --git a/docs/api/api_common_heap.md b/docs/api/api_common_heap.md index ac01afc7..7867f80d 100644 --- a/docs/api/api_common_heap.md +++ b/docs/api/api_common_heap.md @@ -45,10 +45,10 @@ fn len(&self) -> usize fn is_empty(&self) -> bool fn capacity(&self) -> usize fn memory_bytes(&self) -> usize -fn estimated_memory_bytes(heap_size: usize, key: &DataInput) -> usize +fn theoretical_memory_bytes(heap_size: usize, key: &DataInput) -> usize ``` -`memory_bytes` is the bytes the heap holds now. `estimated_memory_bytes` is the +`memory_bytes` is the bytes the heap holds now. `theoretical_memory_bytes` is the bytes a heap of capacity `heap_size` holds in steady state once keys shaped like `key` (owned string or byte length; 0 for a number) churn through it, for pricing a heap before building one. A heap holds at most this, and exactly this diff --git a/docs/api/api_count_min_sketch_with_heap.md b/docs/api/api_count_min_sketch_with_heap.md index 7b487ce1..676adf2a 100644 --- a/docs/api/api_count_min_sketch_with_heap.md +++ b/docs/api/api_count_min_sketch_with_heap.md @@ -60,11 +60,11 @@ fn heap(&self) -> &TopKHeap ```rust fn memory_bytes(&self) -> usize -fn estimated_memory_bytes(rows: usize, cols: usize, heap_size: usize, key: &DataInput) -> usize // Vector2D storage +fn theoretical_memory_bytes(rows: usize, cols: usize, heap_size: usize, key: &DataInput) -> usize // Vector2D storage ``` `memory_bytes` is what the instance holds now: the struct, `rows * cols` -counters and `TopKHeap::memory_bytes`. `estimated_memory_bytes` prices a +counters and `TopKHeap::memory_bytes`. `theoretical_memory_bytes` prices a `new(rows, cols, heap_size)` whose heap is full of keys shaped like `key`. ## Merge diff --git a/docs/api/api_count_sketch_with_heap.md b/docs/api/api_count_sketch_with_heap.md index 740d5489..b6b33d7f 100644 --- a/docs/api/api_count_sketch_with_heap.md +++ b/docs/api/api_count_sketch_with_heap.md @@ -61,11 +61,11 @@ fn heap(&self) -> &TopKHeap ```rust fn memory_bytes(&self) -> usize -fn estimated_memory_bytes(rows: usize, cols: usize, heap_size: usize, key: &DataInput) -> usize // Vector2D storage +fn theoretical_memory_bytes(rows: usize, cols: usize, heap_size: usize, key: &DataInput) -> usize // Vector2D storage ``` `memory_bytes` is what the instance holds now: the struct, `rows * cols` -counters and `TopKHeap::memory_bytes`. `estimated_memory_bytes` prices a +counters and `TopKHeap::memory_bytes`. `theoretical_memory_bytes` prices a `new(rows, cols, heap_size)` whose heap is full of keys shaped like `key`. ## Merge diff --git a/docs/api/api_univmon.md b/docs/api/api_univmon.md index 7c63a4e1..508ab302 100644 --- a/docs/api/api_univmon.md +++ b/docs/api/api_univmon.md @@ -191,13 +191,13 @@ so the layer increments form a martingale difference sequence: unconditionally expectation by `F0 / 2^j` on low layers, and on high layers by `(F0 / 2^j)^3 / (5 k^2)`, using `(x-k)^+ <= x(x-1)(x-2)/(5 k^2)` for `k >= 8`. Splitting at `2^j` in `[0.45, 0.9) * F0/k` gives -`Var[Y*_0] <= 1.35 F0^2/k <= 2 F0^2/k`. +`Var[Y*_0] <= 1.35 F0^2/k`. Let `C` be the event that the top heap is complete (`top_layer_complete()`), and `Y = estimate_cardinality()`. On `C`, `Y = Y*_0`, so Chebyshev gives the **joint** bound -`P(|Y - F0| > epsilon F0 AND C) <= d`, where `d = min(1, 2/(k epsilon^2))`. +`P(|Y - F0| > epsilon F0 AND C) <= d`, where `d = min(1, 1.35/(k epsilon^2))`. Completeness does not imply conditional unbiasedness. With `F0 = 9`, `k = 8`, `L = 2`, any layer-0 heap misses one key. Of 512 equally likely next-bit diff --git a/docs/api/api_univmon_two_tier.md b/docs/api/api_univmon_two_tier.md index d6793beb..92bf6408 100644 --- a/docs/api/api_univmon_two_tier.md +++ b/docs/api/api_univmon_two_tier.md @@ -54,7 +54,7 @@ Count Sketch F2 estimate in standard mode, so its `(ε, δ)` bound uses layer 0' dimensions (the elephant ones whenever `elephant_layers >= 1`); in terminal mode it sums the strata, each held to its own layer's dimensions. `estimate_cardinality()` counts every heap key; it misses by more than `ε F0` -jointly with `top_layer_complete()` with probability at most `2/(kε²)` for +jointly with `top_layer_complete()` with probability at most `1.35/(kε²)` for `k = heap_size >= 8`, independent of the elephant/mouse split. ## Merge diff --git a/docs/sketch_analytics.md b/docs/sketch_analytics.md index a03502f1..0355fc85 100644 --- a/docs/sketch_analytics.md +++ b/docs/sketch_analytics.md @@ -15,8 +15,9 @@ Each op formula is the most one call can cost, unless its section marks it avera - `n` is the number of updates a state has received, `N` their total weight, and `D` the distinct keys among them. - `f(x)` is the total weight of key `x`, and `‖f‖₂` is the square root of the sum of `f(x)²` over all keys. - For a sketch with `d` rows, estimating one key costs `est(d)` ops: `d` for Count-Min, which takes the minimum of `d` counters, and `2d + d·lg d` for Count Sketch, which copies them into a buffer and takes their median. -- A `TopKHeap` update costs `h(k) = 8 + 16·⌊log2 k⌋` for a heap of capacity `k`. - It probes the heap's index, replaces or rescores one slot, then moves the key down at most `⌊log2 k⌋` levels, and each level reads both children, swaps two slots and their digests, and repoints two index entries. +- A `TopKHeap` update costs `h(k) = 8 + 14·⌊log2 k⌋` for a heap of capacity `k`, plus one probe when the key's 64-bit digest matches a resident's, with probability at most `k·2^−64`. + It probes the heap's index, reads the root, and gives the root's slot, digest and index entries to the new key in 8 ops, or rescores a resident's slot in fewer. + It then moves the key at most `⌊log2 k⌋` levels, and each level reads both children, swaps two slots, reads and swaps their digests, and repoints two index entries. Counter matrices pick a column per row in one of two ways. @@ -26,7 +27,7 @@ Counter matrices pick a column per row in one of two ways. - `q` is the probability that two keys share a column. It is at most `1/w + 2^−32` on `RegularPath`. On `FastPath` it is `1/w` when `w` is a power of two, and at most `9/(8w)` otherwise, because a slice taken modulo `w` picks some columns twice as often. - Count-Min bounds use `1/w`, as ASAPPlanner does. + Count-Min bounds use `1/w`, as ASAPPlanner does, except on `FastPath` with `w` not a power of two, where they use the collision rate `(w + 3r)/(w + r)²` with `r = 2^(lg w) − w`. - A median over `d` rows is wrong only when at least half the rows are wrong. With each row wrong independently with probability `β`, that happens with probability `P_med(d, β) = P(Binomial(d, β) ≥ ⌈d/2⌉)`. @@ -39,18 +40,18 @@ Merges follow these rules unless a section says otherwise. ## API -Each sketch type implements one trait, and frameworks call their inner sketch's functions through it. +Each sketch type implements one trait in `src/analytics`, and frameworks call their inner sketch's functions through it. ```rust pub trait SketchAnalytics { type Params; type Data: Default; - fn theoretical_memory_bytes(params: &Self::Params, data: &Self::Data) -> usize; + fn theoretical_memory_bytes(params: &Self::Params, data: &Self::Data) -> Option; fn theoretical_update_ops(params: &Self::Params, data: &Self::Data) -> f64; - fn theoretical_query_ops(statistic: Statistic, params: &Self::Params, data: &Self::Data) -> Option; + fn theoretical_query_ops(statistic: StatisticKind, params: &Self::Params, data: &Self::Data) -> Option; fn theoretical_merge_ops(params: &Self::Params, data: &Self::Data) -> Option; - fn theoretical_error(statistic: Statistic, params: &Self::Params, data: &Self::Data, delta: Option) -> Option; - fn params_for(statistic: Statistic, epsilon: f64, delta: f64, data: &Self::Data) -> Option; + fn theoretical_error(statistic: StatisticKind, params: &Self::Params, data: &Self::Data, delta: Option) -> Option; + fn params_for(statistic: StatisticKind, epsilon: f64, delta: f64, data: &Self::Data) -> Option; } pub struct ErrorBound { @@ -63,11 +64,12 @@ pub struct ErrorBound { - `Params` holds the constructor's parameters, such as `CountMinParams { rows, cols }`. - `Data` holds the data inputs listed in the sketch's section, each as an `Option`. - When an input is unknown, memory and ops use its upper bound from the section, and `theoretical_error` and `params_for` return `None` if they need it. -- `Statistic` is the enum in `src/capability.rs`, plus `QuantileInterpolated` for DdSketch. + When an input is unknown, ops use its upper bound from the section, memory uses that bound or returns `None` when the section gives none, and `theoretical_error` and `params_for` return `None` if they need it. +- A sketch whose bounds hold only for an unmerged state takes a `merged` input, and its `theoretical_error` and `params_for` return those bounds only for `merged = Some(false)`. +- `StatisticKind` is the enum in `src/capability.rs`. - `theoretical_update_ops` is the cost of the plain `update` call, or of `update_weighted` for a sketch that has only that. - `theoretical_query_ops`, `theoretical_merge_ops` and `theoretical_error` return `None` for what the sketch does not do or has no formula for. -- `theoretical_memory_bytes` calls the sketch's inherent `theoretical_memory_bytes`, which is the existing `estimated_memory_bytes` renamed, or uses the section's formula where the sketch has none. +- `theoretical_memory_bytes` calls the sketch's inherent `theoretical_memory_bytes`, or uses the section's formula where the sketch has none. - A caller with parameters calls these functions directly, and a caller with a target `(ε, δ)` first calls `params_for`. - `params_for` returns parameters from the section's sizing rule that meet `(ε, δ)`, or `None` when no parameters the constructor accepts can. Parameters the bound does not depend on come from `Params::default()`. @@ -76,8 +78,8 @@ An `ErrorBound` says: with probability at least `1 − δ`, one estimate from on Several estimates hold together with probability at least `1 − Σδ`. - Each contract says whether its `δ` is fixed by the parameters, as Count-Min's depth fixes it at `e^−d`, or chosen by the caller. - `theoretical_error` with `delta = None` returns the contract's own `δ`. - With a `delta` below a fixed `δ` it returns `None`, and with a chosen `δ` it returns the smallest `ε` at that `delta`. + With a fixed `δ`, `theoretical_error` returns that `δ` for `delta = None` or a `delta` at or above it, and `None` for a `delta` below it. + With a chosen `δ`, it returns the smallest `ε` at `delta`, or at `DEFAULT_DELTA = 0.01` when `delta` is `None`. - `ErrorBound.delta` is `None` when the analysis gives only a standard error. - `ErrorMetric` has the variants of ASAPPlanner's `AccuracyMetric`: `Absolute` (off by at most `ε`), `Absolute(L1)` (at most `ε·N`), `Absolute(L2)` (at most `ε·‖f‖₂`), `Relative` (at most `ε` times the true value) and `Rank` (the returned value's rank is within `ε·n` of the requested one). `Absolute(L1)` stands for `Absolute { scale: Some(NormScale::StreamL1) }`, and likewise for L2. @@ -97,17 +99,17 @@ Query is the sketch's main readout, and each row's symbols are defined in that s | Sketch | Main error | Memory | Update | Query | Merge | Hashes | |---|---|---|---|---|---|---| | CountMinSketch | `ε = e/w`, `δ = e^−d`, `Absolute(L1)` | `O(dw)` | `O(d)` | `O(d)` | `O(dw)` | `d`, or 1 | -| CountSketch | `ε = √(3/w)`, `δ = P_med(d, q·w/3)`, `Absolute(L2)` | `O(dw)` | `O(d)` | `O(d log d)` | `O(dw)` | `d`, or 1 | +| CountSketch | `ε = √(q/β)`, `δ` chosen, `Absolute(L2)` | `O(dw)` | `O(d)` | `O(d log d)` | `O(dw)` | `d`, or 1 | | CountSketchWithL2Norm | as CountSketch | `O(dw)` | `O(d)` | `O(d log d)` | `O(dw)` | 1 | | CountMinHll | none | `O(dw·2^p)` | `O(d)` | `O(d·2^p)` | `O(dw·2^p)` | 2 | -| MicroCm | none | `O(dwT)` | `O(d + dwT/I)` average | `O(dT)` | `O(dwT)` | 1 | -| SpaceSaving | `ε = 1/m`, `δ = 0`, `Absolute(L1)` | `O(m)` | `O(1)` | `O(1)` | `O(m log m)` | 1 | -| ElasticSketch | `ε = e/w`, `δ = e^−d`, `Absolute(L1)` | `O(H + dw)` | `O(d)` | `O(d)` | `O(Hd + dw)` | `1 + d` | +| MicroCm | none | `O(dwT)` | `O(d + dw/I)` average | `O(dT)` | `O(dwT)` | 1 | +| SpaceSaving | `ε = 1/m`, `δ = 0`, `Absolute(L1)` | `O(m)` | `O(1)` | `O(1)` | `O((m + m_o) log(m + m_o))` | 1 | +| ElasticSketch | `ε = e/w`, `δ = e^−min(d, 5)`, `Absolute(L1)` | `O(H + dw)` | `O(d)` | `O(d)` | `O(Hd + dw)` | `1 + d` | | CocoSketch | none | `O(dw)` | `O(d)` | `O(d)` | `O(d²w)` | `d` | | Count-Min or Count Sketch with heap | as the base sketch | `O(dw + k)` | `O(est(d) + log k)` | `O(est(d))` | `O(dw + k(est(d) + log k))` | `2d + 1`, or 3 | | Count-Min or Count Sketch with Space-Saving | as the base sketch | `O(dw + m)` | `O(d)` | `O(est(d))` | `O(dw + m log m)` | `d + 1`, or 2 | -| Count-Min or Count Sketch fold | as the base sketch | `O(d(W/2^F + min(W, D)) + k)` | `O(dE + est(d) + log k)` | `O(dE + est(d))` | `O(dW·2^F)` | `2d + 1` | -| HyperLogLog | `ε = 1.04/√m`, `Relative` | `O(m)` | `O(1)` | `O(m)` | `O(m)` | 1 | +| Count-Min or Count Sketch fold | as the base sketch | `O(d(W/2^F + min(W, D)) + k)` | `O(dE + est(d) + log k)` | `O(dE + est(d))` | `O(d(W/2^F + min(W, D)·E) + k(dE + est(d) + log k))` | `2d + 1` | +| HyperLogLog | `ε = β_m/√m`, `Relative` | `O(m)` | `O(1)` | `O(m)` | `O(m)` | 1 | | HyperLogLogHip | `ε = 0.833/√m`, `Relative` | `O(m)` | `O(1)` | `O(1)` | none | 1 | | Kmv | `δ = e^(−Θ(ε²k))`, `Relative` | `O(k)` | `O(k)` | `O(1)` | `O(k²)` | 1 | | SetAggregator | exact | `O(Dℓ)` | `O(ℓ)` | `O(1)` | `O(D)` | 1 | @@ -131,7 +133,7 @@ Data inputs: none. | Item | Big O | Formula | |---|---|---| | ItemFrequency error | | `Absolute(L1)`, `ε = e/w`, `δ = e^−d` fixed; contract `count_min_l1_markov_v1` | -| ItemFrequency sizing | | `w = ⌈e/ε⌉`, `d = max(1, ⌈ln(1/δ)⌉)`; on `FastPath`, `w` rounded up to a power of two | +| ItemFrequency sizing | | `w = ⌈e/ε⌉`, `d = max(1, ⌈ln(1/δ)⌉)`; on `FastPath`, `w` rounded up to a power of two; `None` when `d > 20`, the ASAPv1 row limit `MATRIX_MAX_ROWS` | | Total error | | exact; contract `exact_v1` | | Memory | `O(dw)` | `size_of::() + d·w·size_of::()` | | Update | `O(d)` | `d` | @@ -155,15 +157,17 @@ Data inputs: none. | Item | Big O | Formula | |---|---|---| -| ItemFrequency error | | `Absolute(L2)`, `ε = √(3/w)`, `δ = P_med(d, q·w/3)` fixed; contract `count_sketch_l2_median_binomial_v1` | -| ItemFrequency sizing | | `w = ⌈3/ε²⌉`, `d` the smallest odd depth with `P_med(d, q·w/3) ≤ δ` | +| ItemFrequency error | | `Absolute(L2)`, `ε = √(q/β)`, `δ` chosen, with `β` the largest per-row rate with `P_med(d, β) ≤ δ`; contract `count_sketch_l2_median_chosen_row_rate_v1` | +| ItemFrequency sizing | | the odd `d ≤ 19` and width `w ≥ 1` with `q ≤ β·ε²` that minimize `d·w`; on `FastPath`, `w` a power of two | | Memory | `O(dw)` | `theoretical_memory_bytes(rows, cols)` | | Update | `O(d)` | `d` | | Query ItemFrequency | `O(d log d)` | `2d + d·lg d` | | Merge | `O(dw)` | `2dw` | - A row is off by more than `ε·‖f‖₂` with probability at most `q/ε²` (Charikar, Chen and Farach-Colton 2002, Section 3). - At `ε = √(3/w)` that is `q·w/3`, at most `1/3 + w/(3·2^32)` on `RegularPath` and `3/8` on `FastPath`. + Setting that to `β` gives `ε = √(q/β)`, and the median is wrong only when `⌈d/2⌉` rows are. +- On `RegularPath` at `δ = 0.01` the sizing takes `d = 5` and `w ≈ 9.5/ε²`. + On `FastPath` rounding `w` up to a power of two moves the best `d`, which ranges from 3 to 9 for `ε` from 0.001 to 0.1. - An update adds the weight with a random sign to one counter per row, and a query copies those counters into a buffer and takes their median. ### CountSketchWithL2Norm @@ -175,6 +179,7 @@ Data inputs: none. | Item | Big O | Formula | |---|---|---| | ItemFrequency error | | as CountSketch, with the `FastPath` `q` | +| ItemFrequency sizing | | as CountSketch on `FastPath`, over the shapes with `d·(lg w + 1) ≤ 128` | | FrequencyL2 error | | `Relative`, `ε = 1 − √(1 − √(20q))`, `δ = P_med(d, 0.1)` fixed, when `20q < 1`; contract `univmon_layer0_f2_median_chebyshev_binomial_v1` | | FrequencyL2 sizing | | `w` the smallest width with `20q ≤ (1 − (1 − ε)²)²`, `d` the smallest with `P_med(d, 0.1) ≤ δ`; `None` when `d > 19` or `d·(lg w + 1) > 128` | | Memory | `O(dw)` | `theoretical_memory_bytes(rows, cols)` | @@ -216,13 +221,13 @@ Data inputs: `I`, the updates per sub-window, the code's `items_per_sub_window` |---|---|---| | ItemFrequency error | | none | | Memory | `O(dwT)` | `size_of::() + 4d·w·⌈(T + 7)/4⌉` | -| Update | `O(d + dwT/I)` average | `6d + 4`, plus `T + 2` per row that zooms out | -| Query ItemFrequency | `O(dT)` | `d(2T + 3) + 2` | +| Update | `O(d + dw/I)` average | `5d + 5dw/I` while no cell has zoomed, with `I = 1` when unknown | +| Query ItemFrequency | `O(dT)` | `d(2T + 3)` | | Merge | `O(dwT)` | `dw·(10(T + 2) + 7)` for `c = 2`, `dw·(8(T + 2) + 7)` otherwise | -- A cell holds `T + 2` one-byte pixels, a zoom byte and a 4-byte shutter. -- An update reads each row's shutter and zoom, adds to one pixel, and advances the clock. -- At each sub-window boundary every cell is rewritten: usually 4 ops, and at most `2T + 10 + Z·(2T + 6)` when it zooms in `Z` times, with `Z` at most `⌊log_c(2^32 − 1)⌋`. +- A cell holds `T + 2` one-byte pixels, a zoom byte and a 4-byte shutter, each one op. +- An update reads each row's shutter and zoom twice, adds to one pixel and writes the shutter, and a row that zooms out also rewrites its `T + 2` pixels and zoom. +- At each sub-window boundary every cell is rewritten: 5 ops while it has not zoomed, and more when it carries a shutter or zooms in. - Zoom-out rounding goes both ways by an amount that depends on the data, so the estimate is neither an upper nor a lower bound. - Merge also requires both states at the same sub-window. @@ -232,56 +237,62 @@ Data inputs: `I`, the updates per sub-window, the code's `items_per_sub_window` `src/sketches/space_saving.rs`, Space-Saving on the Stream-Summary structure (Metwally et al. 2005). Parameter: `m` counters, the code's `capacity`. -Data inputs: the key for memory, and the TopK size `k`. +Data inputs: the bytes each key owns, the TopK size `k`, `m` when unknown, the capacity `m_o` of the state merged in, unbounded when unknown, and `merged`. | Item | Big O | Formula | |---|---|---| | ItemFrequency error | | `Absolute(L1)`, `ε = 1/m`, `δ = 0`; contract `space_saving_min_counter_v1` | | ItemFrequency sizing | | `m = ⌈1/ε⌉` | | TopK error | | each reported count as ItemFrequency | -| Total error | | exact; contract `exact_v1` | -| Memory | `O(m)` | `size_of::() + m·(80 + key bytes) + 80(m + 1) + hash_map_bytes::<(u64, Slot)>(m)` | -| Update | `O(1)` | 45 at unit weight, plus 2 per count bucket passed | +| Memory | `O(m)` | `size_of::() + m·(80 + key bytes) + 40·s(m + 1) + hash_index_bytes::<(u64, Slot)>(m, m)` | +| Update | `O(1)` | 47 at unit weight, plus 2 per count bucket passed | | Query ItemFrequency | `O(1)` | 4 | -| Query TopK | `O(k)` | `1 + 7k` | -| Query Total | `O(1)` | 1 | -| Merge | `O(m log m)` | `36m + 2m·lg(2m) + 17` | +| Query TopK | `O(k)` | `1 + 7·min(k, m)` | +| Merge | `O((m + m_o) log(m + m_o))` | `34m + 10m_o + (m + m_o)·lg(m + m_o) + 13` | - In one unmerged state, an estimate is off by at most `N/m`. The counters sum to `N`, so the smallest is at most `N/m`, a held key overcounts by at most the smallest counter, and a key not held has `f(x)` at most the smallest counter. -- Each entry is 80 bytes plus its key, and each count bucket 32 bytes plus 8 on the free list, in vectors that grow by doubling. +- Total has no accessor, so it has no formula. +- Each entry is 80 bytes plus its key, and each count bucket 32 bytes plus 8 on the free list. + At most `m + 1` buckets exist at once, and `s(n)` is the slots a vector holds after doubling from 4 to `n`. + The key index is std's `HashMap` reserved for `m` keys under churn. - The summary links keys into buckets of equal count. - An update of a held key moves its entry to the next count's bucket, creating it and dropping the old one if needed, at most 34 ops. - A new key in a full summary also takes over the lowest bucket's first entry and swaps the keys in the index, at most 45. -- TopK walks down from the highest bucket, reading each key's entry and links. -- A merge reads both summaries, sorts their `2m` entries by count, and inserts the top `m` at 14 ops each. -- After a merge, ItemFrequency and TopK have no bound, and Total stays exact. + An update of a held key moves its entry to the next count's bucket, creating it and dropping the old one if needed, at most 36 ops. + A new key in a full summary also takes over the lowest bucket's first entry and swaps the keys in the index, at most 47. +- A unit update costs at most 47 in any state, merged or not, because every held count is at least `min_count` and a new key seated at `min_count + 1` passes at most one count bucket. + A weighted update may pass many. +- TopK walks down from the highest bucket, reading each bucket's count and links and each key's entry, and writing each reported key. +- A merge reads both summaries into a temporary map, sorts their up to `m + m_o` entries by count, and seats the top `m` at 22 ops each. +- After a merge, ItemFrequency and TopK have no bound. - Capacities may differ in a merge, which keeps this state's. ### ElasticSketch `src/sketches/elastic.rs`. Parameters: `H` heavy buckets with one flow each, and a light Count-Min of `d × w` `i32` counters, the code's `bucket_count`, `light_rows` and `light_cols`. -Data inputs: `D`, and the key length in bytes for memory. +Data inputs: `D`, the bytes each flow id owns, the capacity of the `String` passed to `update`, and `merged`. | Item | Big O | Formula | |---|---|---| -| ItemFrequency error | | `Absolute(L1)`, `0 ≤ estimate − f(x) ≤ (e/w)·N` with `δ = e^−d` fixed; contract `elastic_one_sided_light_cms_v1` | +| ItemFrequency error | | `Absolute(L1)`, `0 ≤ estimate − f(x) ≤ (e/w)·N` with `δ = e^−min(d, 5)` fixed; contract `elastic_one_sided_light_cms_v1` | | ItemFrequency sizing | | `w = ⌈e/ε⌉`, `d = max(1, ⌈ln(1/δ)⌉)`, `None` for `δ < e^−5`; `H` is not set by the bound | -| TopK error | | each reported count as ItemFrequency, with `δ = D·e^−d` | +| TopK error | | each reported count as ItemFrequency, with `δ = D·e^−min(d, 5)`; `None` when that is at least 1 | +| TopK sizing | | as ItemFrequency at `δ/D` | | Memory | `O(H + dw)` | `size_of::() + H·(40 + key bytes) + 4dw` | -| Update | `O(d)` | `1 + d` | +| Update | `O(d)` | `2 + d` | | Query ItemFrequency | `O(d)` | `1 + d` | -| Query TopK | `O(H(d + log H))` | `H·(1 + d) + 2H·lg H` | -| Merge | `O(Hd + dw)` | `H·(17 + 3d) + 2dw` | +| Query TopK | `O(H(d + log H))` | `H·(5 + d) + 2H·lg H` | +| Merge | `O(Hd + dw)` | `H·(13 + 3d) + 2dw` | - A flow that holds a heavy bucket and has never been evicted is counted exactly. Any other flow's error is the light Count-Min's error on the weight sent to the light part, so the Count-Min bound applies, derived from the update rule. -- The bound holds for `d ≤ 5`, because heavy routing uses hash seed 5 and light row `i` uses seed `i`. +- Heavy routing uses hash seed 5 and light row `i` uses seed `i`, so light rows past the fifth add nothing to `δ`. - The bound does not cover `update_heavy_only`, which drops weight, nor `compress_heavy` and `expand_heavy`, which pick flows by their light estimates. - The bound holds while every flow's count is below `2^28`, where the eviction vote overflows `i32`. - A heavy bucket is 40 bytes plus the key. - An update reads and writes one heavy bucket, and adds to one light counter per row when the flow does not stay there. + While copies left by `expand_heavy` remain, it reads the bucket once more. +- TopK reads each heavy bucket up to three times, estimates its flow, writes it to a buffer, and sorts the buffer twice. - A merge clones and compares each pair of heavy buckets by estimating both flows, sends the loser into the light part, and adds the light counters. - After a merge, no readout has a bound. @@ -289,22 +300,22 @@ Data inputs: `D`, and the key length in bytes for memory. `src/sketches/coco.rs`. Parameters: `d` rows and `w` columns, the code's `rows` and `cols`. -Data inputs: `D`, and the key length in bytes for memory. +Data inputs: `D`, and the bytes each key owns. | Item | Big O | Formula | |---|---|---| | ItemFrequency, PartialKeySum error | | none | -| Total error | | exact; contract `exact_v1` | | Memory | `O(dw)` | `size_of::() + 32d·w + min(d·w, D)·key bytes` | -| Update | `O(d)` | `d` | +| Update | `O(d)` | `d + 1` | | Query ItemFrequency | `O(d)` | `d` | -| Query PartialKeySum, Total | `O(dw)` | `dw` | -| Merge | `O(d²w)` | `dw·(1 + d)` | +| Query PartialKeySum | `O(dw)` | `dw` | +| Merge | `O(d²w)` | `dw·(d + 2)` | - The paper guarantees only that estimates are unbiased, which gives no bound on one estimate. -- `update_weighted` reads one bucket per row, adds the weight to the smallest, and may replace that bucket's key. -- Total has no accessor and sums every bucket, since every update adds its weight to exactly one bucket. -- A merge reads every bucket of the other state and replays it into this one as an update, computing `d` hashes each. +- Total has no accessor, so it has no formula. +- `update_weighted` is the update, and it reads one bucket per row, then adds the weight to the smallest and may replace that bucket's key. +- PartialKeySum is `estimate_projected`, `estimate_with_udf` or `estimate_substring`, each reading every bucket once. +- A merge reads every bucket of the other state and replays each held key into this one as an update, computing `d` hashes each. ## Counter matrices with top-k @@ -315,41 +326,48 @@ Each section covers both bases, through `est(d)` and the base sketch's `δ`. `src/sketches/countminsketch_heap.rs` and `src/sketches/countsketch_heap.rs`. Parameters: `d` rows, `w` columns and heap capacity `k`, the code's `rows`, `cols` and `heap_size`, plus counter type and path. -Data inputs: `D`, and the key (`DataInput`) for memory. +Data inputs: `D`, and the bytes each key owns. | Item | Big O | Formula | |---|---|---| -| ItemFrequency error | | as the base sketch | -| TopK error | | each reported count as ItemFrequency, with `D` times the base sketch's `δ` | -| Memory | `O(dw + k)` | `theoretical_memory_bytes(rows, cols, heap_size, key)` | +| ItemFrequency error | | as the base sketch, with its contract | +| ItemFrequency sizing | | as the base sketch; `k` is not set by the bound | +| TopK error | | each reported count as ItemFrequency at a per-key `δ` of `δ/D`; for Count-Min `δ = D·e^−d` fixed, `None` when that is at least 1; `None` without `D` | +| TopK sizing | | as ItemFrequency at `δ/D` | +| Memory | `O(dw + k)` | `size_of::() − size_of::() + d·w·size_of::() + TopKHeap::theoretical_memory_bytes(k, key)` | | Update | `O(est(d) + log k)` | `d + est(d) + h(k)` | | Query ItemFrequency | `O(est(d))` | `est(d)` | | Query TopK | `O(k)` | `k` | -| Merge | `O(dw + k(est(d) + log k))` | `2dw + u·(3 + est(d) + h(k))`, with `u` the two heaps' sizes summed | +| Merge | `O(dw + k(est(d) + log k))` | `2dw + u·(4 + est(d) + h(k))`, with `u ≤ 2·min(k, D)` the two heaps' sizes summed | +- On `Vector2D` storage the memory formula is the inherent `theoretical_memory_bytes(rows, cols, heap_size, key)`. - An update adds to `d` counters, estimates the key from them, then updates the heap. - TopK returns the heap's `k` entries unsorted. -- A merge adds the counters, then re-estimates each key from both heaps into a new heap of this state's capacity. +- A merge adds the counters, copies both heaps' keys into a buffer, clears the heap, then re-estimates each key into it at this state's capacity. +- Clearing a `TopKHeap` drops each resident and its index entry, 2 ops per resident. - A key's stored count is its estimate at its own last update, and its true count has not changed since, so the stored count keeps the base sketch's bound. +- CountSketchWithHeap over integer counters stores the median truncated to an integer, which adds up to 0.5 to a reported count's error for even `d`. ### CountMinSketchWithSpaceSaving and CountSketchWithSpaceSaving `src/sketches/countminsketch_space_saving.rs` and `src/sketches/countsketch_space_saving.rs`. Parameters: `d` rows, `w` columns and `m` Space-Saving counters, the code's `rows`, `cols` and `capacity`, plus counter type and path. -Data inputs: `D`, and the key for memory. +Data inputs: `D`, unbounded when unknown, and the bytes each key owns. | Item | Big O | Formula | |---|---|---| -| ItemFrequency error | | as the base sketch | -| TopK error | | each reported count as ItemFrequency, with `min(m, D)` times the base sketch's `δ` | -| Memory | `O(dw + m)` | the base sketch's memory plus SpaceSaving's with `m` counters | -| Update | `O(d)` | `d + 45` | +| ItemFrequency error | | as the base sketch, with its contract | +| ItemFrequency sizing | | as the base sketch; `m` is not set by the bound | +| TopK error | | each reported count as ItemFrequency at a per-key `δ` of `δ/min(m, D)`; for Count-Min `δ = min(m, D)·e^−d` fixed, `None` when that is at least 1 | +| TopK sizing | | as ItemFrequency at `δ/min(m, D)` | +| Memory | `O(dw + m)` | `size_of::() − size_of::() + d·w·size_of::() + SpaceSaving::theoretical_memory_bytes(m, key bytes)` | +| Update | `O(d)` | `d + 47` at unit weight, plus 2 per count bucket passed | | Query ItemFrequency | `O(est(d))` | `est(d)` | -| Query TopK | `O(m·est(d) + m log m)` | `m'·(7 + est(d)) + m'·lg m'`, with `m' = min(m, D)` | -| Merge | `O(dw + m log m)` | `2dw + 36m + 2m·lg(2m) + 17` | +| Query TopK | `O(m·est(d) + m log m)` | `m'·(5 + est(d)) + m'·lg m'`, with `m' = min(m, D)` | +| Merge | `O(dw + m log m)` | `2dw + 44m + 2m·lg(2m) + 13` | -- An update adds to `d` counters and offers the key to a SpaceSaving summary. -- TopK reads each key in the summary, estimates it from the counters, and sorts. +- An update adds to `d` counters and offers the key to a SpaceSaving summary, whose update costs as in its section. +- TopK copies each key in the summary with its count into a buffer, estimates it from the counters into a second buffer, and sorts. - A merge adds the counters and merges the summaries as SpaceSaving does, and requires equal `m`. - With nonnegative integer weights, in one unmerged state, every key with `f(x) > N/m` is in the summary. `f64` weights enter the summary rounded up, so this does not hold for fractional weights. @@ -357,26 +375,28 @@ Data inputs: `D`, and the key for memory. ### CountMinSketchFold and CountSketchFold `src/sketches/count_min_fold.rs` and `src/sketches/count_sketch_fold.rs`. -Parameters: `d` rows, full width `W` (a power of two), fold level `F` and heap capacity `k`, the code's `rows`, `full_cols`, `fold_level` and `heap_size`. +Parameters: `d` rows, full width `W` (a power of two, at most 65536), fold level `F` and heap capacity `k`, the code's `rows`, `full_cols`, `fold_level` and `heap_size`. Each row stores `W/2^F` cells, and each cell is a short list of (full column, count) entries. -Data inputs: `D`; `E`, the most entries one cell holds, at most `min(2^F, D)`; `S`, the entries in cells holding more than one, at most `d·min(W, D)`; and the key for memory. +Data inputs: `D`; `E`, the most entries one cell holds, at most `min(2^F, D)`; `S`, the entries in cells holding more than one, at most `d·min(W, D)`; the bytes each key owns; and `merged`, whether `merge_same_level` has merged into the state. | Item | Big O | Formula | |---|---|---| -| ItemFrequency error | | as the base sketch on `RegularPath`, with `w = min(W, 65536)` | -| TopK error | | as the heap variant, for a state not produced by `merge_same_level` | +| ItemFrequency error | | as the base sketch on `RegularPath`, with `w = W` | +| ItemFrequency sizing | | as the base sketch on `RegularPath`, with `W` rounded up to a power of two; `None` when `W > 65536`; `F = min(3, log2 W)` and `k = 32` | +| TopK error | | as the heap variant, for a state `merge_same_level` has not merged into | +| TopK sizing | | as ItemFrequency at `δ/D` | | Memory | `O(d(W/2^F + min(W, D)) + k)` | `size_of::() − size_of::() + 24d·W/2^F + 32S + TopKHeap::theoretical_memory_bytes(k, key)` | | Update | `O(dE + est(d) + log k)` | `2dE + d + est(d) + h(k)` | | Query ItemFrequency | `O(dE + est(d))` | `dE + est(d)` | | Query TopK | `O(k)` | `k` | -| Merge, same level | `O(dW·2^F)` | `dW·2^F + k·(dE + est(d) + h(k))` | +| Merge, same level | `O(d(W/2^F + min(W, D)·E) + k(dE + est(d) + log k))` | `dW/2^F + d·min(W, D)·(2 + E) + min(k, D)·(1 + dE + est(d) + h(k))` | - A cell is 24 bytes, and each entry in a list is 16 bytes with the list grown by doubling, so at most 32. +- Reading a cell costs 1, and each listed entry it scans costs 1 more. - An update scans one cell per row for its entry and adds to it, scans the same cells again to estimate the key, then updates the heap. -- A same-level merge adds each entry of the other state into this state's matching cell, then re-estimates only the other heap's keys, so this state's heap counts go stale. -- `hierarchical_merge` accepts mixed levels and costs `dW + 2·Σ e_i + Σ k_i·(d + est(d) + h(k))`, where state `i` holds `e_i` entries and `k_i` heap keys. +- A same-level merge reads each cell of the other state and adds each of its entries into this state's matching cell, then re-estimates only the other heap's keys, so this state's heap counts go stale. +- `hierarchical_merge` accepts mixed levels and costs `dW + Σ (dW/2^F_i + 2e_i + k_i·(1 + est(d) + h(k)))`, where state `i` is at level `F_i` and holds `e_i` entries and `k_i` heap keys. - The fold level does not change the error. -- Columns are stored as `u16`, so a width above 65536 behaves as 65536. - CountSketchFold truncates an even-`d` median to an integer, which adds up to 0.5 to its error. ## Cardinality and membership @@ -389,21 +409,23 @@ Data inputs: `max_distinct`, an upper bound on `D`. | Item | Big O | Formula | |---|---|---| -| Distinct error | | `Relative`, `ε = 1.04/√m`, `δ` unknown; contract `generic_hll_rse_only_no_confidence_v1` | -| Distinct error, `Classic` with `max_distinct` | | `Relative`, `δ = max over D ≤ max_distinct of λ^t/t!`, with `λ = D(D − 1)/(2m)` and `t = ⌊εD⌋ + 1`; contract `classic_hll_linear_counting_collision_bound_v1` | -| Distinct sizing | | the smallest `p` meeting the second contract when it applies, otherwise `p = ⌈2·log2(1.04/ε)⌉` | +| Distinct error | | `Relative`, `ε = β_m/√m`, `δ` unknown, none for `p < 4`; contract `generic_hll_rse_only_no_confidence_v1` | +| Distinct error, `Classic` with `max_distinct` | | `Relative`, `δ` chosen, `ε` the smallest from `max_distinct/(2(m − max_distinct))` to 1 with `max over D ≤ max_distinct of λ^t/t! ≤ δ`, where `λ = D(D − 1)/(2m)` and `t = ⌊εD⌋ + 1`, when that `ε` is at most `β_m/√m`; contract `classic_hll_linear_counting_collision_bound_v1` | +| Distinct sizing | | the smallest `p` whose error is the second contract meeting `(ε, δ)`, otherwise the smallest `p ≥ 4` with `β_m/√m ≤ ε` | | Memory | `O(m)` | `theoretical_memory_bytes(precision)` | | Update | `O(1)` | 1 | | Query Distinct | `O(m)` | `m` for `Classic`, `2m + 66 − p` for `Ertl` | | Merge | `O(m)` | `2m` | -- `1.04/√m` is a standard error, not a bound with a probability (Flajolet et al. 2007, Theorem 1). -- The second contract applies when `max_distinct ≤ m/2`, `max_distinct ≤ 2^17`, `max_distinct/(2(m − max_distinct)) ≤ ε`, and `p` is from 4 to 18. - There `Classic` always uses linear counting, and `λ^t/t!` bounds the chance that `t` keys land on registers already taken. +- `β_m/√m` is a standard error, not a bound with a probability (Flajolet et al. 2007, Theorem 1). + `β_m` is 1.106, 1.070, 1.054 and 1.046 for `m` = 16, 32, 64 and 128, and 1.04 above. +- The second contract applies when `max_distinct ≤ m/2`, `max_distinct ≤ 2^17` and `p` is from 4 to 18, and its `ε` is at most 1 and at most `β_m/√m`. + There `Classic` always uses linear counting, which overcounts by at most `max_distinct/(2(m − max_distinct))` of `D` and undercounts by more than `εD` only when `t` keys land on registers already taken, with probability at most `λ^t/t!`. + `λ^t/t!` is not monotone in `D`, so the maximum is taken over every `D`. - `Ertl` does not use linear counting, so only the standard error applies to it. -- An update raises one register. +- An update reads one register and writes it when the new rank is larger. A `Classic` query reads every register, and an `Ertl` query also fills and reads a histogram of `64 − p + 2` bins. -- Inputs may overlap. +- A merge reads each register of the other state and raises this state's, and inputs may overlap. ### HyperLogLogHip @@ -414,74 +436,79 @@ Data inputs: none. | Item | Big O | Formula | |---|---|---| | Distinct error | | `Relative`, `ε = √(ln 2)/√m ≈ 0.833/√m`, `δ` unknown; contract `hip_hll_rse_only_no_confidence_v1` | -| Distinct sizing | | `p = ⌈2·log2(0.833/ε)⌉` | +| Distinct sizing | | `p = ⌈2·log2(√(ln 2)/ε)⌉` | | Memory | `O(m)` | `theoretical_memory_bytes(precision)` | -| Update | `O(1)` | 4 | +| Update | `O(1)` | 6 | | Query Distinct | `O(1)` | 1 | - The HIP estimator is unbiased with variance about `ln 2·D²/m` (Cohen 2015, Ting 2014), so `ε` is a standard error, for a state built by inserts from one stream. -- An update reads one register, and when the register grows it writes it and three running totals. +- An update reads one register. + When the register grows it writes it, reads the two running sums of `2^−register`, adds to the running estimate, and moves the register's term between the sums. - The running estimate depends on arrival order, so two states do not merge. ### Kmv `src/sketches/kmv.rs`. Parameter: `k ≥ 2`. -Data inputs: `n`, `D`. +Data inputs: `D`, unbounded when unknown. | Item | Big O | Formula | |---|---|---| -| Distinct error | | `Relative`, exact while `D < k`, `δ = exp(−ε²(k − 1)/((1 + ε)(2 + ε))) + exp(−ε²(k − 1)/(2(1 − ε)))`, the second term 0 for `ε ≥ 1`; contract `kmv_exact_below_k_chernoff_v1` | -| Distinct sizing | | the smallest `k` meeting `δ`, such as 4323 at `ε = 0.05`, `δ = 0.01` | -| Memory | `O(k)` | `theoretical_memory_bytes(k, distinct)` | -| Update | `O(k)` | `k + 3 + 8·⌊log2 k⌋` | -| Query Distinct | `O(1)` | 2 | -| Merge | `O(k²)` | `k·(k + 4 + 8·⌊log2 k⌋)` | - -- The sketch keeps the `k` smallest hashes, and the estimate is `k − 1` divided by the largest of them scaled to (0, 1) (Beyer et al. 2007). +| Distinct error | | `Relative`; `ε = δ = 0` for a known `D < k`; otherwise `δ` chosen and `ε` the smallest with `exp(−ε²(k − 1)/((1 + ε)(2 + ε))) + exp(−ε²(k − 1)/(2(1 − ε))) ≤ δ`, the second term 0 for `ε ≥ 1`; `None` for a NaN or negative `δ`, and for `δ = 0` unless `D < k`; contract `kmv_exact_below_k_chernoff_v1` | +| Distinct sizing | | the smallest `k` meeting `(ε, δ)`, such as 4323 at `ε = 0.05`, `δ = 0.01`, or `D + 1` for a known `D` when smaller | +| Memory | `O(k)` | `theoretical_memory_bytes(k, D)` | +| Update | `O(k)` | `k + 3 + 6·⌊log2 k⌋` | +| Query Distinct | `O(1)` | 1 | +| Merge | `O(k²)` | `k·(k + 4 + 6·⌊log2 k⌋)` | + +- The sketch keeps the `k` smallest hashes, and the estimate is `k − 1` divided by the largest of them scaled to (0, 1) (Beyer et al. 2007), or the count of kept hashes while fewer than `k` are kept. The two terms of `δ` are Chernoff bounds on overestimating and underestimating by more than `ε`. -- An update reads the largest kept hash and stops if the sketch is full and the new hash is larger. - Otherwise it reads the kept hashes one by one looking for a duplicate, then adds the hash to the heap, which costs 8 ops per level it moves. +- An update reads the largest kept hash when the sketch is full, and stops if the new hash is not smaller. + Otherwise it reads the kept hashes one by one looking for a duplicate, then pushes the hash. + A full heap re-reads and replaces its root and sifts it down at 6 ops per level, and a heap with room appends the hash and sifts it up at 5 per level. - With every key equally frequent and arriving in random order, the duplicate scan reads about `D/2` hashes on average when `D ≤ k` and about `k²/(2D)` when `D > k`. +- A query reads the largest kept hash, or nothing while fewer than `k` are kept. - The heap starts at `min(k, 1024)` slots and doubles, so it can hold up to about `2k`. -- A merge offers each of the other state's hashes as an update, and inputs may overlap. +- A merge reads each of the other state's hashes and offers it as an update, and inputs may overlap. ### SetAggregator `src/sketches/set_aggregator.rs`. No parameters. -Data inputs: `D`, and the mean key length `ℓ` in bytes. +Data inputs: `D`, unbounded when unknown, and the mean key length `ℓ` in bytes. | Item | Big O | Formula | |---|---|---| | Distinct, Membership error | | exact; contract `exact_v1` | -| Memory | `O(Dℓ)` | `size_of::() + hash_map_bytes::(D) + D·ℓ` | +| Memory | `O(Dℓ)` | `size_of::() + hash_map_bytes_after_inserts::(D) + ⌈D·ℓ⌉` | | Update | `O(ℓ)` | 1 | -| Query Distinct, Membership | `O(1)` | 1 | -| Merge | `O(D)` | one per key of the other state | +| Query Distinct | `O(1)` | 0 | +| Query Membership | `O(1)` | 1 | +| Merge | `O(D)` | `2D`, with `D` the other state's keys | - An update probes the set once, after copying the key into a new `String`. -- Inputs may overlap. +- Distinct reads the set's length, and `contains` probes the set once. +- A merge reads each key of the other state and probes this state with it, and inputs may overlap. ### Bloom `src/sketches/bloom.rs`, partitioned. -Parameters: `r` slices and `c` bits per slice, the code's `rows` and `cols` and Kirsch and Mitzenmacher's `k` and `m'`. +Parameters: `r` slices and `c` bits per slice, the code's `rows` and `cols` and Kirsch and Mitzenmacher's `k` and `m'`, plus path. Data inputs: `D`. | Item | Big O | Formula | |---|---|---| -| Membership error | | `Absolute`, `ε = 0`, `δ = (1 − (1 − 1/c)^D)^r` for a power-of-two `c` up to `2^32`; contract `bloom_partitioned_fpr_v1` | -| Membership sizing | | the `r ≤ 20` and power-of-two `c` that minimize `r·c` and meet `δ` | -| Memory | `O(rc)` bits | `size_of::() + 8r·⌈c/64⌉` | -| Update | `O(r)` | `r + 1` on `RegularPath`, `2r + 1` on `FastPath` | +| Membership error | | `Absolute`, `ε = 0`, `δ = (1 − (1 − 1/c)^D)^r` fixed, for a power-of-two `c` up to `2^32`; `None` without `D`; contract `bloom_partitioned_fpr_v1` | +| Membership sizing | | the `r ≤ BLOOM_MAX_SLICES` and power-of-two `c` with `r·c ≤ BLOOM_MAX_BITS` that minimize `r·c` and meet `δ`; `None` without `D` | +| Memory | `O(rc)` bits | `theoretical_memory_bytes(rows, cols) = size_of::() + 8r·⌈c/64⌉` | +| Update | `O(r)` | `r` | | Query Membership | `O(r)` | `r` | -| Merge | `O(rc)` | `2r·⌈c/64⌉ + 2` | +| Merge | `O(rc)` | `2r·⌈c/64⌉` | - An inserted key always reads as present, and a key that was not reads as present with probability exactly `δ`, if it was chosen without knowing the hash seeds. - For other `c` the columns are not equally likely, so `theoretical_error` returns `None`. -- `dimensions_for(n, δ)` solves an approximation of this rate, and is not `params_for`. -- An update sets one bit per slice and adds to the inserted count, and a query reads one bit per slice, stopping at the first zero on `RegularPath`. +- `dimensions_for(n, δ)` meets this rate without minimizing `r·c`, and is not `params_for`. +- An update sets one bit per slice, and a query reads one bit per slice, stopping at the first zero on `RegularPath`. - A merge ORs the bit arrays a 64-bit word at a time, and inputs may overlap. ## Quantiles and sampling @@ -495,75 +522,90 @@ Data inputs: `n`. Kll keeps items in levels, and when a level fills it sorts it, keeps every other item at double weight, and passes them up. The code clamps `m` into [2, 26602] and then `k` into [`m`, 26602], and below `k` and `m` mean the clamped values. Level `i` from the top holds at most `cap(i) = max(⌈k·(2/3)^i⌉, m)` items. -After `n` updates there are `L(n)` levels, the paper's `H`: 1 when `n ≤ k` and `2 + ⌊log2(n/(k + 1))⌋` otherwise. -The sketch keeps `R(n) = min(n, cap(0) + … + cap(L(n) − 1))` items, at most `3k + (m + 1)·L(n)`, and the bottom level holds at most `c_b = cap(L(n) − 1)`. +`L(n)` is 1 when `n ≤ k` and `2 + ⌊log2(n/(k + 1))⌋` otherwise, the paper's `H`. +After `n` updates a state holds `L(n) − 1` to `L(n) + 1` levels, the most when halving odd-sized levels, which gains or loses one item's weight each time, has gained weight. +`L̂(n)` is `L(n) + 1` for `n > k` and 1 otherwise. +A state holds at most `R̂(n) = min(n, cap(0) + … + cap(L̂(n) − 1))` items, and its bottom level at most `ĉ_b = min(n, cap(max(L(n) − 2, 0)))`. +`c_b = cap(L(n) − 1)`. +In the formulas, `R` and `L` are a state's items and levels, and the trait puts `R̂(n)` and `L̂(n)` in their place. | Item | Big O | Formula | |---|---|---| | Quantile error | | `Rank`, `ε = 2.296·s(δ)/k^0.9723`, `δ` chosen; contract `kll_quantile_empirical_99_v1`, or `kll_quantile_empirical_99_delta_scaled_v1` for `δ < 0.01` | -| Quantile sizing | | `k = ⌈(2.296·s(δ)/ε)^(1/0.9723)⌉`, `m = 8`, `None` when `k > 26602` | -| Rank error | | none, see open questions | +| Quantile sizing | | `k = max(8, ⌈(2.296·s(δ)/ε)^(1/0.9723)⌉)`, `m = 8`, `None` when `k > 26602` | +| Rank error | | `Absolute(L1)`, `ε = 2.07·s(δ)/k^0.9646`, `δ` chosen, none for `k < 32`; contract `kll_rank_measured_99_v1`, or `kll_rank_measured_99_delta_scaled_v1` for `δ < 0.01` | +| Rank sizing | | `k = max(32, ⌈(2.07·s(δ)/ε)^(1/0.9646)⌉)`, `m = 8`, `None` when `k > 26602` | | Memory | `O(k + m·log(n/k))` | `theoretical_memory_bytes(k, m, n)` | -| Update | `O(log k)` average | `26 + lg(c_b + 1)` | -| Query Quantile | `O(R log R)` | `3R + R·lg R + lg R` | -| Query Rank | `O(R)` | `R` | -| Merge | `O(R + B log B)` | `B·lg B + 25R − 11B + 10L + 2(L_a + L_b) + 74`, with `R = R_a + R_b` and `B` the two bottom levels' sizes summed | +| Update | `O(log k)` average | `15 + lg(c_b + 1)` | +| Query Quantile | `O(R log R)` | `3R + R·lg R + 2L + lg R + 2` | +| Query Rank | `O(R)` | `R + 2L` | +| Merge | `O(R + B log B)` | `B·lg B + 16R + 8L + 40`, with `R = R_a + R_b`, `B` the two bottom levels' sizes summed and `L` the merged state's levels | - `2.296/k^0.9723` is an empirical fit: with 99% probability, the returned value's rank is within `ε·n` of the requested rank. On asap_sketchlib it measures at 0.81 to 0.95 times the fit with `m = 8` and at 1.07 times with `m = 2`, so `theoretical_error` returns `None` for `m < 8`. +- `rank(x)` returns a count, off by at most `ε·n` with 99% probability, and `None` for `m < 8` as well. + The 99th percentile of `|rank(x) − true rank|/n` over 1000 seeded trials and 9 query points, at `n = 10^6`, `m = 8` and `k` from 32 to 4096, regresses to `1.8647/k^0.9646`. + The constant 2.07 makes the fit an upper envelope of every measured `k`, since a `k` that is not a power of two measures higher. - `s(δ)` is 1 for `δ ≥ 0.01`. - Below that it is `z(1 − δ/2)/z(1 − 0.005)`, with `z` the standard normal quantile, which assumes the error is Gaussian and is not measured, and the second contract name marks this. -- An update appends to the bottom level at 6 ops, and when that level is full it sorts it, halves it into the level above, and repeats while levels overflow. - Averaged over updates, the sort costs `lg(c_b + 1)` and the halving and merging at most 20. + Below that it is `z(1 − δ/2)/z(1 − 0.005)`, with `z` the standard normal quantile, which assumes the error is Gaussian and is not measured, and the `delta_scaled` contract names mark this. +- An update appends to the bottom level at 3 ops, and when that level is full it sorts it, halves it into the level above, and repeats while levels overflow. + Averaged over the first `n` updates, the sort costs `lg(c_b + 1)` and the rest at most 15, and with `n` unknown `c_b` is `k`. - The worst single update compacts every level, at most `(c_b + 1)·lg(c_b + 1) + 12R − 4c_b + 15L + 18 + (L − 1)(L + 2)/2`. -- A quantile query copies the `R` items, sorts them by value, accumulates their weights, and binary-searches, and `quantile_cached` reuses that until the next update at `lg R` per query. -- A merge copies both states into one buffer, merges each level, sorts the bottom level, halves each overflowing level once, and writes back all 62 level boundaries. +- A quantile query copies the `R` items, sorts them by value, accumulates their weights, and binary-searches, and `quantile_cached` reuses that until the next update at `lg R + 2` per query. +- A rank query reads every item and each level's two bounds. +- A merge copies both states into one buffer per level, merges each level, sorts the bottom level, halves each overflowing level until it fits, and writes back all 62 level bounds. +- `theoretical_merge_ops` takes two states of `n` updates each, with `B ≤ 2ĉ_b` and `L ≤ L̂(2n)`. +- The update constants bound the counted ops for `k` from 2 to 26602 and `m` from 2 to 20 over up to `4·10^6` updates, and the merge constants bound them over about 7000 merges with `k` from 2 to 4096, states at and around each level boundary included. - Quantiles 0 and 1 are exact only before the first compaction, because the extremes are not stored. - Merge also requires independently seeded inputs, since states built with the same constant seed make the same coin flips. ### KllDynamic `src/sketches/kll_dynamic.rs`. -Parameters, data inputs and clamping as Kll. +Parameters, data inputs, clamping and shapes as Kll. | Item | Big O | Formula | |---|---|---| -| Quantile error | | as Kll | -| Memory | `O(k + m·log(n/k))` | `size_of::() + 8·max(3k, 2(R(n) + 1)) + 16·(L(n) + 1)` | -| Update | `O(log k)` average | `5 + lg(c_b + 1) + lg(1.5k)` | -| Query Quantile | `O(R log R)` | `3R + R·lg R + lg R` | -| Query Rank | `O(R)` | `R` | -| Merge | `O(R log k)` | each level's size times its `lg`, summed over both states, plus `25R − 6B + 11L + 2(L_a + L_b) + 11` | - -- Every compaction sorts its level again, including the top level of about `1.5k` items, and a merge sorts every level of both states first. +| Quantile and Rank error and sizing | | as Kll | +| Memory | `O(k + m·log(n/k))` | `size_of::() + size_of::()·max(3k, 2(R̂(n) + 1)) + 16·(L̂(n) + 1)` | +| Update | `O(log k)` average | `11 + 2·lg(c_b + 1)` | +| Query Quantile | `O(R log R)` | `3R + R·lg R + 2L + lg R + 2` | +| Query Rank | `O(R)` | `R + 2L` | +| Merge | `O(R log k)` | `S + 16R + 8L`, with `S` each level's size times its `lg`, summed over both states | + +- Every compaction sorts its level again, and averaged over the first `n` updates the levels above the bottom add at most another `lg(c_b + 1)`. +- A merge sorts every level of both states first, and `theoretical_merge_ops` bounds `S` by `2·Σ s_i·lg s_i` over `i < L̂(n)`, with `s_i = min(cap(i), n)`. - Merge requirements are Kll's. ### DdSketch `src/sketches/ddsketch.rs`. Parameter: `α` in (0, 1), the code's `alpha`, with `γ = (1 + α)/(1 − α)`. -Data inputs: `n`, the smallest and largest magnitude, whether negative values occur, and the largest absolute value `A`. +Data inputs: `n`, the smallest nonzero magnitude, the largest magnitude `A`, and whether negative values occur. | Item | Big O | Formula | |---|---|---| | Quantile error | | `Relative`, `ε = α`, `δ = 0`; contract `ddsketch_relative_error_alpha_v1` | -| Quantile sizing | | `α = ε` | -| QuantileInterpolated error | | `Relative`, `ε = α` when all values share a sign, contract `ddsketch_interpolated_relative_alpha_v1`; otherwise `Absolute`, `ε = α·A`, contract `ddsketch_interpolated_absolute_alpha_v1` | -| Total, Min, Max error | | exact; contract `exact_v1` | -| Sum error | | `Absolute`, `ε = (n − 1)·2^−53` times the sum of absolute values, `δ = 0`; contract `ddsketch_f64_sum_rounding_v1` | +| Quantile sizing | | `α = min(ε, 1/2)` | +| QuantileInterpolated error | | `δ = 0`; `Relative`, `ε = α` when no value is negative, contract `ddsketch_interpolated_relative_alpha_v1`; otherwise `Absolute`, `ε = α·A`, contract `ddsketch_interpolated_absolute_alpha_v1` | +| QuantileInterpolated sizing | | `α = min(ε, 1/2)`, or `α = min(ε/A, 1/2)` for the `Absolute` bound | | Memory | `O(log(max/min)/α)` per sign | `theoretical_memory_bytes(alpha, n, min_magnitude, max_magnitude, negatives)` | -| Update | `O(1)` | 6 | -| Query Quantile | `O(B)` | `B` | -| Query Total, Sum, Min, Max | `O(1)` | 1 | -| Merge | `O(B)` | `B_a + B_b` plus the slots between their lowest and highest buckets | +| Update | `O(1)` | 4 | +| Query Quantile | `O(B)` | `B + 4` | +| Query QuantileInterpolated | `O(B)` | `2B + 7` | +| Merge | `O(B)` | `2(B_a + B_b) + 11` | - For every quantile level `φ`, the returned value is within `α` times the true `⌈φ·n⌉`-th value, because the store never collapses (Masson, Rim and Lee 2019). +- An interpolated quantile is within `α·((1 − f)·|lower| + f·|upper|)` of the true one, with `f` the fractional rank, which is `α` relative when both neighbours share a sign. - A value `x` goes to bucket `⌊log_γ x⌋`, whose representative is `γ^i·(1 + α)`. - A store spans `s` buckets from the smallest to the largest magnitude and holds at most `2(s + 126)` slots, the 126 being growth headroom, and `B` is the slots of both signs' stores. -- Magnitudes below about `2e−308` read as 0, and magnitudes above the largest indexable value are dropped, so both are outside the bound. -- After `apply_delta`, which adds another state's bucket counts, Sum is within `α` times the sum of absolute values, and Min and Max are within `α` relative. -- An update adds to the count, sum, min and max, reads the store's offset, and adds to one bucket. -- A quantile query walks the slots until the cumulative count passes `φ·n`. + A store spans `s` buckets from the smallest to the largest magnitude and holds at most `2(s + 126)` slots, the 126 being growth headroom, and `B` is the slots of both signs' stores, which the trait takes from the memory formula. +- Nonzero magnitudes below the smallest value `ddsketch_indexable_bounds(α)` returns read as 0, and magnitudes above the largest are dropped. + The bounds are about `2e−308` and `1e308`, and narrow for `α` below about `1.6e−7`. + Error and sizing return `None` when a known magnitude is outside them, negative or NaN, or `α` is outside (0, 1), and memory returns `None` for such an `α`. +- Sum, Min and Max are not sketch readouts: the state is the two stores and the zero count. +- An update adds to the count, reads the store's offset, and adds to one bucket, or to the zero count, and reads the offset once more after growing the store. +- A quantile query reads the count, both offsets and the zero count, and walks the slots until the cumulative count passes the rank, and an interpolated one walks twice. +- A merge reads every slot of both states and adds it into a store spanning both, whose allocation is container growth, and `theoretical_merge_ops` takes two states with the same data inputs. - Merge requires `α` equal within `1e−12`. ### UniformSampling @@ -575,16 +617,17 @@ Data inputs: `n`, and the sample holds `s = ⌈ρ·n⌉` items. | Item | Big O | Formula | |---|---|---| | Sample error | | none | -| Total error | | exact; contract `exact_v1` | | Memory | `O(ρn)` | `48 + 16·max(4, 2s)` | -| Update | `O(ρn)` | `2 + lg(s + 1) + s` | -| Query Sample | `O(s)` | `s` | -| Merge | `O(s log s)` | `(s_a + s_b)·lg(s_a + s_b)` | +| Update | `O(ρn)` | `3 + lg s + 2s` | +| Query Sample | `O(s)` | `2s` | +| Merge | `O(s log s)` | `3 + 2(s_a + s_b) + (s_a + s_b)·lg(s_a + s_b)` | - The sample is not uniform. When `⌈ρ·t⌉` grows at arrival `t`, that arrival is always kept, and an item dropped earlier never comes back. At `n = 1000` and `ρ = 0.1`, an item's chance of being kept rises from 0.077 in the first tenth of the stream to 0.125 in the last. +- Sample is its only readout, and `total_seen()` is a count kept beside the sample. - An update binary-searches the sorted sample, shifts later items to insert, and may drop the last one. +- A query copies the sample out, and a merge copies both samples into one buffer, sorts it and truncates it, and `theoretical_merge_ops` takes two states of `n` updates each. - Merge requires `ρ` equal within `f64::EPSILON`. - `new()` uses a fixed seed, so two samplers built with `new()` make the same choices at the same positions, and their merge is correlated. @@ -596,32 +639,35 @@ UnivMon keeps `L` layers, and an update goes to layer 0 and to each further laye `src/sketch_framework/univmon.rs`. Parameters: heap capacity `k`, `d` rows, `w` columns and `L ≤ 19` layers, the code's `heap_size`, `rows`, `cols` and `layers`; each layer is a `CountSketchWithL2Norm` and a heap of `k` keys. -Data inputs: `D_max`, an upper bound on `D`, and the key for memory. +Data inputs: `D_max`, an upper bound on `D`, and the bytes each key owns. +`c = min(k, D_max)` is the most keys one layer's heap holds, and `k` when `D_max` is unknown. | Item | Big O | Formula | |---|---|---| | Total error | | exact; contract `univmon_unit_update_total` | -| FrequencyL2 error | | as CountSketchWithL2Norm on layer 0 | -| Distinct error | | `Relative`, `ε = √(1.35/(k·(δ − ψ)))`, `δ` chosen, `k ≥ 8`; contract `univmon_distinct_complete_top_layer_chebyshev_v1` | -| Distinct sizing | | the smallest `k ≥ 8` with `1.35/(k·ε²) + (e/4)^(k/2) ≤ δ`, and `L ≥ lg(D_max/k) + 2`; `None` when that needs `L > 19` | +| FrequencyL2 error | | as CountSketchWithL2Norm on layer 0, with its contract | +| FrequencyL2 sizing | | as CountSketchWithL2Norm for `d` and `w`; `k` and `L` are not set by the bound | +| Distinct error | | `Relative`, `ε = √(1.35/(k·(δ − ψ)))`, `δ` chosen, `k ≥ 8`; contract `univmon_distinct_complete_top_layer_chebyshev_v1`; `None` without `D_max` | +| Distinct sizing | | the smallest `k ≥ 8` with `1.35/(k·ε²) + (e/4)^(k/2) ≤ δ`, and `L = lg(D_max/k) + 2`; `None` when that needs `L > 19` | | FrequencyEntropy, GSum error | | none | | Memory | `O(L(dw + k))` | `theoretical_memory_bytes(heap_size, rows, cols, layers, key)` | -| Update | `O(d log d + log k)` average | `1 + λ(L)·(5d + d·lg d + 9 + 16·⌊log2 k⌋)` | +| Update | `O(d log d + log k)` average | `1 + λ(L)·(5d + d·lg d + 9 + 14·⌊log2 k⌋)` | | Query Total | `O(1)` | 1 | | Query FrequencyL2 | `O(d log d)` | `2d + d·lg d` | -| Query Distinct, GSum, FrequencyEntropy | `O(min(Lk, LD)·d log d)` | `min(Lk, L·D)·(1 + 2d + d·lg d)` | -| Merge | `O(L(dw + k(d log d + log k)))` | `2 + L·(3dw + d + 3 + k·(18 + 4d + 2d·lg d + 30·⌊log2 k⌋))` | +| Query Distinct, GSum, FrequencyEntropy | `O(min(Lk, LD)·d log d)` | `4 + 2L + L·c·(1 + 2d + d·lg d)` | +| Merge | `O(L(dw + k(d log d + log k)))` | `2 + L·(3dw + d + 3 + c·(24 + 4d + 2d·lg d + 28·⌊log2 k⌋))` | - Distinct uses a Chebyshev bound on the heap estimator, whose variance is at most `1.35·D²/k`. - `ψ` is the probability that the top layer sees more than `k` keys: 0 when `D_max ≤ k`, `(e/4)^(k/2)` when `L ≥ lg(D_max/k) + 2`, and 1 otherwise. `theoretical_error` returns `None` when `delta ≤ ψ`. - A merged state keeps the Distinct bound, because its top layer overflows only if the union's does. - Entropy and G-sum are computed from the layers' heaps, and nothing proves those heaps are large enough for the paper's guarantee. -- The bounds cover states built with `update`, and `theoretical_error` returns `None` for a state built with `update_weighted_terminal`. +- The bounds and the update formula cover states built with `update_weighted`, not with `update_weighted_terminal`. - `L ≤ 19` because hash seed 19 selects the layers. -- An update adds to the running total, then in each layer it reaches updates `d` counters and row sums, estimates the key, updates that layer's heap, and records whether the heap is full. -- Distinct, GSum and Entropy re-estimate every key in every layer's heap. -- A merge rebuilds each layer as CountSketchWithL2Norm and the heap variants do, and requires the same update mode or one side empty. +- An update adds to the running total, then in each layer it reaches updates `d` counters and row sums, estimates the key, updates that layer's heap, and clears the layer's candidate flag when the heap turned a key away. +- Distinct, GSum and Entropy read the total, re-estimate every key in every layer's heap, and write and read one partial sum per layer; Entropy reads the total three more times. +- A merge adds the totals, then per layer reads both candidate flags, reads both heaps' keys into a set, merges the counters, clears the heap, re-estimates each key in the set into it, and writes the flag. +- A merge requires the same update mode or one side empty. ### UnivMonTwoTier @@ -633,8 +679,9 @@ Data inputs as UnivMon. | Item | Big O | Formula | |---|---|---| | Error, every readout | | as UnivMon, with layer 0's dimensions for FrequencyL2 | -| Memory | as the summary | `size_of::()`, plus each layer's `CountSketchWithL2Norm::theoretical_memory_bytes`, plus `L·TopKHeap::theoretical_memory_bytes(k, key)` | -| Update | as UnivMon | `1 + λ_e·(5d_e + d_e·lg d_e + 9 + 16·⌊log2 k⌋) + λ_m·(5d_m + d_m·lg d_m + 9 + 16·⌊log2 k⌋)` average | +| Sizing | | as UnivMon; FrequencyL2 sets the elephant dimensions | +| Memory | as the summary | `theoretical_memory_bytes(heap_size, elephant_layers, elephant_rows, elephant_cols, mouse_rows, mouse_cols, layers, key)`: `size_of::()`, plus per layer its `CountSketchWithL2Norm::theoretical_memory_bytes`, `TopKHeap::theoretical_memory_bytes(k, key)` and a candidate flag | +| Update | as UnivMon | `1 + λ_e·(5d_e + d_e·lg d_e + 9 + 14·⌊log2 k⌋) + λ_m·(5d_m + d_m·lg d_m + 9 + 14·⌊log2 k⌋)` average | | Query, Merge | as UnivMon | as UnivMon, with each layer's own `d` and `w` | - An update reaches `λ_e = 2 − 2^(1 − min(L_e, L))` elephant layers and `λ_m = λ(L) − λ_e` mouse layers on average. @@ -643,38 +690,49 @@ Data inputs as UnivMon. `src/sketch_framework/univmon_q.rs`. Parameters, all fields of `UnivMonQConfig`: `L` layers, top-layer width `W`, halving period `P` (0 for none), `d` odd rows, `b` counter bits, heap capacity `k` and `M` ordered samples, the config's `layers`, `top_layer_cols`, `cols_halving_period`, `rows`, `counter_bits`, `heap_size` and `ordered_samples`, plus a seed index. -Layer `j` has width `w_j = max(W >> ⌊j/P⌋, min(k, W))`, or `W` when `P = 0`, and `w_min` is the narrowest. +Layer `j` has width `w_j = max(W >> ⌊j/P⌋, min(k, W))`, or `W` when `P = 0`. +`q` is the most collision probability over the layers: `1/w_j` for a power-of-two `w_j`, and `min(1/w_j + 2^(−lg W), 9/(8·w_j))` otherwise. Updates are unit-weight `f64` values, and each update goes to exactly one layer. -Data inputs: `n`. +Data inputs: `n`, and `F̂₂`, an upper bound on the state's `estimate_f2()`. | Item | Big O | Formula | |---|---|---| -| Total, Min, Max error | | exact; contract `exact_v1` | -| ItemFrequency error | | `Absolute(L2)`, `ε = √(3/w_min)`, `δ = P_med(d, q·w_min/3)` with `q ≤ min(1/w_min + 2^(−lg W), 9/(8·w_min))`; contract `univmonq_terminal_count_sketch_l2_median_binomial_v1` | -| Quantile error | | `Rank`, `ε = √(ln(2/δ)/(2·min(M, n)))`, `δ` chosen, when the heavy set is empty; contract `univmonq_rank_diffuse_occurrence_hoeffding_v1` | -| Rank error | | `Absolute(L1)`, the same `ε` and condition | -| Quantile, Rank sizing | | `M = ⌈ln(2/δ)/(2ε²)⌉` | +| ItemFrequency error | | `Absolute(L2)`, `ε = √(q/β)` with `β` the largest per-row failure probability with `P_med(d, β) ≤ δ`, `δ` chosen; contract `univmonq_terminal_count_sketch_l2_median_chosen_row_rate_v1` | +| ItemFrequency sizing | | the odd `d` and power-of-two `W`, with `P = 0`, that meet `(ε, δ)` with the fewest counters `d·W` and fit the 128-bit hash | +| Quantile error | | `Rank`, `ε = √(ln(2/δ)/(2·min(M, n))) + 1/n`, `δ` chosen, when `F̂₂·M < n²`; contract `univmonq_rank_diffuse_occurrence_hoeffding_v1`; `None` without `n` or `F̂₂` | +| Rank error | | `Absolute(L1)`, the same `ε`, condition and contract | +| Quantile, Rank sizing | | `M = ⌈ln(2/δ)/(2(ε − 1/n)²)⌉`; `None` without `n` or `F̂₂`, or when `ε ≤ 1/n`, `n < M` or `F̂₂·M ≥ n²` | | FrequencyL2, Distinct, GSum, FrequencyEntropy, TopK error | | none | -| Memory | `O(d·Σw_j + Lk + M)` | `size_of::() + L·size_of::() + d·(b/8)·Σw_j + 50Lk + 24M` | -| Update | `O(d log d + k log k + log M)` | `18 + 3d + d·lg d + 10·⌊log2 k⌋ + k·(11 + 10·⌊log2 k⌋) + (10 + 10·⌊log2 M⌋ when M > 0)` | -| Query Total, Min, Max | `O(1)` | 1 | -| Query ItemFrequency | `O(d log d)` | `2d + d·lg d` | -| Query every other readout | as the summary | rebuilds every layer's candidate keys first | -| Merge | as the summary | rebuilds the candidate keys and merges the counters and ordered samples | +| Memory | `O(d·Σw_j + Lk + M)` | `theoretical_memory_bytes(config)`: `size_of::() + L·size_of::() + d·(b/8)·Σw_j + L·(T(k) + 16k) + 24M`, with `T(k)` the bytes of a churned hash table of `k` `(u64, u64)` entries | +| Update | `O(d log d + k log k + log M)` | `21 + 3d + d·lg d + 7·⌊log2 k⌋ + k·(12 + 7·⌊log2 k⌋) + (11 + 7·⌊log2 M⌋ when M > 0)` | +| Query ItemFrequency | `O(d log d)` | `1 + 2d + d·lg d` | +| Query every other readout | as the summary | none; it rebuilds every layer's candidate keys first | +| Merge | as the summary | `6 + 2d·Σw_j + L·(4 + k·(15 + 4d + 2d·lg d + 2·lg k + 2·⌊log2 k⌋)) + M·(12 + 7·⌊log2 M⌋)` | +- The count, min and max are exact, and UnivMonQ's capability row does not list Total, so they have no rows. - ItemFrequency reads only the layer the key went to, whose columns are `lg W` hash bits modulo `w_j`. - Rank and Quantile read a uniform sample of `M` updates, so Hoeffding's inequality for sampling without replacement gives the bound. - The heavy set is the frequent values the readout handles separately, and when it is not empty the error depends on the data. + `rank` rounds to a count and `quantile` targets the rank `⌈qn⌉`, which adds the `1/n`. + The heavy set is the frequent values the readout handles separately, and `ordered_query_diagnostics().heavy` reports it. + The readout leaves it empty when its F2 estimate is below `n²/M`, and when it is not empty the error depends on the data. - FrequencyL2, Distinct and GSum come from candidate keys cut off by a threshold, so UnivMon's bounds do not carry over. -- An update adds to the count, min, max and sequence number, adds to one counter per row in its layer, estimates the key, updates the candidate heap, and adds to the ordered sample. -- An update of a key already in the candidate heap costs `5 + 3d + d·lg d` plus the ordered sample, and the `k` term comes from re-queuing up to `k` stale heap entries when a new key enters a full layer. +- A clone or decoded state reserves the candidate tables, candidate heaps and ordered sample a built one does. +- A `BinaryHeap` push into a heap that then holds `n` entries counts `3 + 2·⌊log2 n⌋`. +- A pop from `n` entries counts `7 + 5·⌊log2 n⌋`: std moves the last entry to the root, sifts the hole to the bottom, and sifts it back up. +- An update reads and increments the sequence number, adds to the count, min and max, adds to one counter per row in its layer, estimates the key, updates the candidate table and heap, and adds to the ordered sample. +- An update of a key already in the candidate table costs `6 + 3d + d·lg d` plus the ordered sample, and the `k` term comes from re-queuing up to `k` stale heap entries when a new key enters a full layer. +- A merge adds the counts, mins and maxes, then per layer adds the counters, reads both candidate tables into a set, re-estimates and sorts the set, and refills the table and heap with its top `k`; it then adds each of the other state's samples to the ordered sample. - Merge also requires the same seed index and a distinct source id, the id of the stream a state was built from, on each input. ## Frameworks A framework's formulas use its inner sketch's trait functions. -`U_in`, `Q_in`, `M_in` and `mem_in` are the inner update, query and merge ops and memory, `ε_in` and `δ_in` the inner error bound, and `C_in` the inner sketch's counters, registers or items, which a clone or clear touches. +`U_in`, `Q_in`, `M_in` and `mem_in` are the inner update, query and merge ops and memory, `ε_in` and `δ_in` the inner error bound, and `C_in` the cells a clone or clear of one inner state touches, its heap bytes over the size of one counter, register or item (`InnerSketch::theoretical_cells`). +Reading or writing one entry of a framework's own grid, bucket list, pool, queue or sketch list counts one op, and shifting `j` entries of a list counts `2j`. +A list built with capacity `c` that holds at most `m` entries has `c` slots, or `max(2c, 2(m − 1), 4)` once it grows. A Kll bound needs independently seeded states, so a framework that merges Kll states cloned from one prototype or built from one seed gives its Kll readouts no bound. +Where a framework's cells can be several kinds, `Params` carries the kind and its parameters (`HydraCell`, `EhCell`, `EnsembleCell`), and `CellData` carries each kind's inner data. +A framework bound that adds `c` inner failures to its own `δ_f` has a fixed `δ = δ_f + c·δ_in` when the inner `δ` is fixed, and gives the inner bound `(δ − δ_f)/c` when the caller chooses `δ`. ### Hydra @@ -682,7 +740,7 @@ A Kll bound needs independently seeded states, so a framework that merges Kll st It holds a `d × w` grid of cells, each a Count-Min, Count Sketch, HyperLogLog (`Ertl`), Kll or UnivMon. Parameters: `d` rows, `w` columns and schema arity `a ≤ 16`, the code's `rows`, `cols` and the length of `schema` and the paper's `r`, `w` and `D`, plus the cell's parameters. An update goes to `2^a − 1` subkeys, one per nonempty subset of the schema's fields, and a subpopulation `Q` is one subkey value. -Data inputs: `N`, `n`, the label bytes, and lower bounds on `Q`'s weight `N_Q`, distinct count `D_Q` and `‖f_Q‖₂` for the readouts that use them. +Data inputs: `N`, `n`, the label bytes, lower bounds on `Q`'s weight `N_Q`, distinct count `D_Q` and `‖f_Q‖₂` for the readouts that use them, and `merged`, whether another Hydra has been merged into this one. Cells use the `FastPath` column rule. In each row, other subkeys that share `Q`'s cell add at most `4N(2^a − 1)/w` weight to it with probability at least `1 − q·w/4`, by Markov's inequality. @@ -690,25 +748,29 @@ A query takes the median over rows, which fails with probability `P_med(d, q·w/ | Readout | Bound | δ | Contract | |---|---|---|---| -| Count-Min frequency | `Absolute(L1)`, `ε = 4(2^a − 1)(1 + ε_in)/w + ε_in` | `P_med(d, q·w/4) + d·δ_in` | `hydra_cm_cell_markov_median_binomial_v1` | -| Count-Min group total | `Absolute(L1)`, `ε = 4(2^a − 1)/w`, never under | `(q·w/4)^d` | `hydra_cm_group_total_markov_min_v1` | -| Count Sketch frequency | `Absolute(L1)`, `ε = 4(2^a − 1)(1 + ε_in)/w + ε_in` | `P_med(d, q·w/4) + d·δ_in` | `hydra_cs_cell_markov_median_binomial_v1` | -| Kll Rank, Quantile | `Absolute`, `ε = 4N(2^a − 1)/(w·N_Q) + ε_in`, on the rank scaled to [0, 1] | `P_med(d, q·w/4) + d·δ_in` | `hydra_kll_cell_mixture_rank_median_binomial_v1` | -| HyperLogLog Distinct | `Relative`, `ε = ε_in + (1 + ε_in)·4n(2^a − 1)/(w·D_Q)` | unknown | `hydra_hll_cell_collision_median_binomial_v1` | +| Count-Min ItemFrequency | `Absolute(L1)`, `ε = 4(2^a − 1)(1 + ε_in)/w + ε_in` | `P_med(d, q·w/4) + d·δ_in` | `hydra_cm_cell_markov_median_binomial_v1` | +| Count-Min group total, Total | `Absolute(L1)`, `ε = 4(2^a − 1)/w`, never under | `(q·w/4)^d` | `hydra_cm_group_total_markov_min_v1` | +| Count Sketch ItemFrequency | `Absolute(L1)`, `ε = 4(2^a − 1)(1 + ε_in)/w + ε_in` | `P_med(d, q·w/4) + d·δ_in` | `hydra_cs_cell_markov_median_binomial_v1` | +| Kll Quantile | `Rank`, `ε = 4N(2^a − 1)/(w·N_Q) + ε_in` | `P_med(d, q·w/4) + d·δ_in` | `hydra_kll_cell_mixture_rank_median_binomial_v1` | +| Kll Rank, from `query_cdf` | `Absolute`, the same `ε` on the rank scaled to [0, 1] | `P_med(d, q·w/4) + d·δ_in` | `hydra_kll_cell_mixture_rank_median_binomial_v1` | | UnivMon Total | `Absolute(L1)`, `ε = 4(2^a − 1)/w` | `P_med(d, q·w/4)` | `hydra_univmon_total_markov_median_binomial_v1` | | UnivMon FrequencyL2 | `Relative`, `ε = ε_in + (1 + ε_in)·4N(2^a − 1)/(w·‖f_Q‖₂)` | `P_med(d, q·w/4) + d·δ_in` | `hydra_univmon_l2_collision_median_binomial_v1` | -| UnivMon Distinct | as HyperLogLog Distinct | `P_med(d, q·w/4) + d·δ_in` | `hydra_univmon_distinct_collision_median_binomial_v1` | -| Kll group total, UnivMon FrequencyEntropy | none | | | +| UnivMon Distinct | `Relative`, `ε = ε_in + (1 + ε_in)·4n(2^a − 1)/(w·D_Q)` | `P_med(d, q·w/4) + d·δ_in` | `hydra_univmon_distinct_collision_median_binomial_v1` | +| HyperLogLog Distinct, Kll group total, UnivMon FrequencyEntropy | none | | | -- A HyperLogLog cell counts colliding updates, not their weight, so its term uses `n`, and its `δ` is unknown because `Ertl` gives only a standard error. +- A UnivMon Distinct cell counts colliding updates, not their weight, so its term uses `n`. +- A HyperLogLog cell's `Ertl` bound is a standard error with no `δ`, and the collision term holds only with probability `1 − P_med(d, q·w/4)`, so no one `(ε, δ)` states both. - Hydra never merges its own cells, so Kll cells keep their bound. -- `params_for` gives half of `ε` to collisions, `ε_in = (ε/2)/(1 + ε/2)` to the cells, and splits `δ` the same way. +- `params_for` sizes ItemFrequency with Count-Min cells, Quantile and Rank with Kll and FrequencyL2 with UnivMon, for the arity in `HydraData::arity`, 1 when unknown. + It gives half of `ε` to collisions, `ε_in = (ε/2)/(1 + ε/2)` to the cells, and splits `δ` the same way. It sets `w` to the smallest power of two that keeps the collision term at most `ε/2`, `d` to the smallest odd depth with `P_med(d, 1/4) ≤ δ/2`, and the cells to the inner `params_for(ε_in, δ/(2d))`. -- Memory: `size_of::() + d·w·size_of::() + (d·w + 1)·(mem_in − size_of::())` plus the label bytes. -- An update writes one cell per row for each subkey, `(2^a − 1)·d·U_in`, and a Kll cell repeats its update once per unit of weight. +- Memory: `size_of::() + d·w·size_of::() + (d·w + 1)·(mem_in − size_of::()) + 2a·size_of::() + 2·label bytes`, where the label bytes include escapes, exact for Count-Min cells when no label needs escaping. +- An update reads one cell per row for each subkey and updates it, `(2^a − 1)·d·(1 + U_in)`, and a Kll cell repeats its update once per unit of weight. - An update computes `(2^a − 1)` grid hashes, `d` each when the slices do not fit, plus the cells' own hashes, since each cell hashes the value again. -- A query reads one cell per row and one prototype cell, then takes the median, `(d + 1)·Q_in + d·lg d`. -- A merge merges every cell, `d·w·M_in`, and merging two Hydras with Kll cells gives those readouts no bound. +- A query probes the prototype cell, reads one cell per row into a buffer and takes the median, `(d + 1)·Q_in + 2d + d·lg d`. + A Kll Rank builds the cell's CDF, so its `Q_in` is the Kll Quantile's. +- A group total reads one cell per row and sums each cell's mass, `d + (d + 1)·T`, with `T = w_in` for a Count-Min cell's row 0 and `T = 2L̂` for a Kll cell's levels. +- A merge reads both grids and merges every cell, `d·w·(2 + M_in)`, and merging two Hydras with Kll cells gives those readouts no bound. - Kll cells require nonnegative weights. ### ExponentialHistogram @@ -717,65 +779,73 @@ A query takes the median over rows, which fails with probability `P_med(d, q·w/ It keeps an inner sketch per bucket over a sliding window and merges older buckets together. Parameters: `k` and the window length, the code's `k` and `window` (in time units, where the paper's `N` counts elements). It merges buckets by their L2 mass when the inner sketch is `CountSketchWithL2Norm` or `UnivMon`, and by their arrival count otherwise. -Data inputs: `N_w` the arrivals in the window, `D_w` the distinct keys in the window, and `G` the number of buckets. +Data inputs: `N_w` the arrivals in the window and `G` the most buckets the histogram keeps. | Readout | Bound | δ | Contract | |---|---|---|---| -| Count-Min or Count Sketch frequency, by count | `Absolute(L1)`, `ε = ε_EH + ε_in(1 + ε_EH)` | `δ_in` | `eh_l1_boundary_bucket_v1` | -| HyperLogLog Distinct, by count | `Relative`, `ε = ε_in + (1 + ε_in)·ε_EH·N_w/D_w` | unknown | `eh_l1_boundary_bucket_v1` | -| L2 readouts, by mass | `Relative`, `ε = ε_EH2 + ε_in(1 + ε_EH2)` | `(G + 2)·δ_mass + δ_in` | `eh_l2_boundary_bucket_mass_v1` | -| Kll; DdSketch Quantile, Min and Max; CocoSketch; ElasticSketch; UniformSampling | none | | | +| Count-Min or Count Sketch ItemFrequency, by count | `Absolute(L1)`, `ε = ε_EH + ε_in(1 + ε_EH)` | `δ_in` | `eh_l1_boundary_bucket_v1` | +| FrequencyL2, by mass | `Relative`, `ε = ε_EH2 + ε₂(1 + ε_EH2)` | `(G + 2)·δ_mass + δ_in` | `eh_l2_boundary_bucket_mass_v1` | +| HyperLogLog Distinct; Kll; DdSketch; CocoSketch; ElasticSketch; UniformSampling | none | | | - Merging by count, the oldest bucket holds at most `ε_EH = 2/(⌈k/2⌉ + 1)` of the window, about `4/k`, and it is kept or dropped whole. This counts arrivals, so these bounds require unit-weight updates. -- Merging by mass compares squared L2 estimates, so `ε_EH2 = ((1 + ε₂)/(1 − ε₂))·√(2/k)`, where `ε₂` is the inner L2 estimate's error and `δ_mass` its failure probability. +- Merging by mass compares squared L2 estimates, so `ε_EH2 = ((1 + ε₂)/(1 − ε₂))·√(2/k)`, where `ε₂` is layer 0's L2 estimate error and `δ_mass` its failure probability, which is also `δ_in`. +- A HyperLogLog bucket's `Ertl` bound is a standard error with no `δ`, and the boundary bucket's term is a worst case, so no one `(ε, δ)` states both. - Every bucket starts as a clone of one prototype, so Kll buckets get no bound. - The bounds hold for windows that end at the newest arrival, and a query endpoint that falls in a gap between two buckets selects the wrong bucket. -- Merging by count keeps at most `G ≤ (⌊k/2⌋ + 2)(⌊log2(2N_w)⌋ + 1)` buckets. -- Memory: `size_of::() + G·size_of::() + (G + 1)·(mem_in − size_of::())`. -- An update clones the prototype into a new bucket (`C_in`), updates it (`U_in`) and scans the buckets (`G`). - Merging by count adds at most one merge per update on average, `C_in + M_in + G`. - Merging by mass, with `E` the cost of one L2 estimate, an update that triggers `g` merges costs `C_in + U_in + E + (2g + 1)·G + g·(C_in + M_in + E)`. -- A query clones one bucket and merges the others in the range into it, at most `2G + C_in + G·M_in`. +- Merging by count keeps at most `G ≤ (⌊k/2⌋ + 2)(⌊log2(2N_w)⌋ + 1)` buckets, which the functions use when `G` is unknown; merging by mass needs `G`. +- Memory: `size_of::() + max(4, 2G)·size_of::() + (G + 1)·(mem_in − size_of::())`, the bucket list's slots grown for `G + 1` buckets. +- An update scans and drops the expired buckets, clones the prototype into a new bucket (`C_in`), updates it (`U_in`), and scans the buckets for merges. + Each merge clones the newer bucket, merges it into the older one and shifts the list. + Merges remove buckets one for one, so on average an update makes at most one, and costs `2C_in + U_in + M_in + 8G + 4` merging by count. + Merging by mass adds an L2 estimate `E = Q_in(FrequencyL2)` for the new bucket and per merge, and three reads per bucket per scan, `2C_in + U_in + M_in + 2E + 10G + 4` on average. +- A query scans the buckets, clones one and merges the others in the range into it, then reads the result, at most `2G + 4 + C_in + G·M_in + Q_in`. -`EhBucketSketch` (`src/sketch_framework/eh_bucket_sketch.rs`) wraps one inner sketch, and its analytics are that sketch's. +`EhBucketSketch` (`src/sketch_framework/eh_bucket_sketch.rs`) wraps one inner sketch, and its analytics are that sketch's, with `EhCell` as its `Params`. ### ExponentialHistogramUnivMon `src/sketch_framework/eh_univmon.rs`, experimental. It is an exponential histogram that merges by L2 mass, with exact `HashMap` buckets for recent data and UnivMon buckets for older data. -Data inputs: `G_s` the UnivMon buckets, and the keys in the exact buckets. +Data inputs: `G_s` and `G_e` the most UnivMon and exact buckets it holds, `K` the most keys its exact buckets hold together, the bytes each key owns, and whether a query reads only exact buckets. -- Only FrequencyL2, as `Relative`, and ItemFrequency, as `Absolute(L2)`, have bounds, because only L2 mass decides the merges. -- From the exact buckets, `ε = √(2/k)` and `δ = 0`, the whole error coming from the oldest bucket being kept or dropped whole; contract `eh_univmon_exact_boundary_bucket_v1`. -- From the UnivMon buckets, `ε = ε_EH2 + ε_in(1 + ε_EH2)` and `δ = (G_s + 2)·δ_mass + δ_in`; contract `eh_univmon_l2_boundary_bucket_v1`. -- Memory: the UnivMon pool's allocated sketches times `mem_in`, plus the exact buckets' keys. -- An update inserts into a map, scans and merges the exact buckets, and when the oldest exact bucket moves to UnivMon it inserts its keys, `keys·U_in`. -- A query over the UnivMon buckets clones one and merges the rest, `C_in + G_s·M_in`. +- Only FrequencyL2, as `Relative`, has a bound, because only L2 mass decides the merges, and a query result answers no item frequency. +- A query that reads only exact buckets has `ε = √(2/k)` and `δ = 0`, the whole error coming from the oldest bucket being kept or dropped whole; contract `eh_univmon_exact_boundary_bucket_v1`. +- A query that reads UnivMon buckets has `ε = ε_EH2 + ε₂(1 + ε_EH2)` and `δ = (G_s + 2)·δ_mass + δ_in`, with `δ_in = δ_mass` from layer 0; contract `eh_univmon_l2_boundary_bucket_v1`. +- Memory: `size_of::() + max(4, 2G_s)·size_of::() + max(4, 2G_e)·size_of::() + S_f·size_of::() + A·(mem_in − size_of::()) + G_e·(map(K) + K·key bytes)`. + `A = max(pool_initial_size, G_s + 1)` is the most sketches the pool allocates, `S_f` its free list's slots, and `map(K)` the bytes of a `HashMap` after `K` inserts. +- An update costs at most `K·(1 + U_in + 2G_e) + 2G_e² + 10G_e + 2G_s² + G_s·(2C_in + M_in + 3E + 7) + 3E + 6`, with `E` the cost of layer 0's L2 estimate, on average over the hashes. + It drops expired buckets and recycles their sketches (`C_in` each), inserts into a new map, scans and merges the exact buckets, rereading each merged map, and when the oldest exact bucket moves to UnivMon it inserts its keys, `K·U_in`, then scans and merges the UnivMon buckets. +- A query scans both lists, then clones one UnivMon bucket and merges the rest, or merges the exact maps, or both and inserts the merged map into a fresh UnivMon, at most `2(G_s + G_e) + 6 + C_in + G_s·(1 + M_in) + G_e + K·(3 + U_in) + Q_in`. ### NitroBatch `src/sketch_framework/nitro.rs`. It admits each update with probability `ρ`, the code's `rate` and the paper's `p`, into a Count-Min or Count Sketch, and an admitted update writes all `d` rows with weight about `1/ρ`. +Its analytics hold for the inner types that implement `NitroInner`, the `FastPath` Count-Min and Count Sketch over `Vector2D`. Data inputs: `N`. | Readout | Bound | δ | Contract | |---|---|---|---| -| Count-Min frequency | `Absolute(L1)`, `ε = ε_s + ε_in(1 + ε_s)` with `ε_s = √(2V/(N·(δ − P_med(d, q·w/e))))` | `δ` chosen | `nitro_batch_item_sampling_chebyshev_v1` | -| Count Sketch frequency | none | | | +| Count-Min ItemFrequency | `Absolute(L1)`, `ε = ε_s + ε_in(1 + ε_s)` with `ε_s = √(2V/(N·(δ − P_med(d, q·w/e))))` | `δ` chosen | `nitro_batch_item_sampling_chebyshev_v1` | +| Count Sketch ItemFrequency | none | | | - `V = (1 − ρ)/ρ + ρ·u(1 − u)`, with `u` the fractional part of `1/ρ`, is the sampling variance per unit of count, at most `1/ρ`. - The estimate is a median over rows, so Count-Min's `e^−d` does not apply, and `ε_in = e/w`. - The bound covers `update_batch`, not `update_batch_cached_step`, and states built with the same seed admit the same positions. - `theoretical_error` returns `None` when `delta ≤ P_med(d, q·w/e)` or `N` is unknown. -- Memory: `size_of::>() + mem_in − size_of::()`. -- An update writes `ρ·d` counters on average, a query reads `d` and takes their median, `2d + d·lg d`, and a merge costs `d·w`. +- Memory: `size_of::>() + mem_in − size_of::()`, exact. +- An update of a batch writes `ρ·d` counters on average, a query reads `d` into a buffer and takes their median, `2d + d·lg d`, and a merge is the inner merge, `M_in`. ### Octo `src/sketch_framework/octo.rs`, behind the `octo-runtime` feature. `K` workers keep 1-byte counters and send a cell's count to a parent sketch when it reaches a threshold, which adapts between `τ_min` and `τ_max ≤ 127`. -`K` is the code's `num_workers` and the paper's `k`, the thresholds are `min_threshold` and `max_threshold`, and `K'` is 1 when `partition` splits keys by hash and `K` when it goes round robin. +`K` is the code's `num_workers` and the paper's `k`, and `K'` is 1 when `partition` splits keys by hash and `K` when it goes round robin. +`τ_min` is the smaller of the initial `threshold` and `min_threshold`, each within [1, 127], or the fixed `threshold` without `adaptive`. +`τ_max` is the larger of the initial `threshold` and `max_threshold` raised to at least `min_threshold`, each within [1, 127], or the fixed `threshold` without `adaptive`. +A runtime has no one sketch type, so its analytics are the functions `octo_error`, `octo_memory_bytes` and `octo_update_ops` in `analytics::frameworks`, over an `OctoConfig` and an `OctoParent`. +The Count-Min and Count Sketch parents hash on `RegularPath`, and the HyperLogLog parent is `Classic`. | Parent | After the queues drain, before a flush | After flush | |---|---|---| @@ -795,29 +865,37 @@ Data inputs: `N`. `src/sketch_framework/tumbling.rs`. It keeps up to `max_windows` closed windows of an inner sketch, aligned to multiples of `window_size`, plus the active one, and takes sketches from a pool of `pool_initial_size`. +Its analytics hold for inner types that implement `TumblingWindowSketch` and `InnerSketch`: `CountMinSketchFold`, `CountSketchFold`, `Kll` and `UnivMonQ`. +Data inputs: `t` the closed windows a query merges, `max_windows` when unknown, and `b` the window boundaries one update crosses, unbounded when unknown. - A query merges the chosen windows, so its error is the inner bound over exactly their updates. -- Memory: `size_of::() + A·mem_in`, with `A = max(pool_initial_size, max_windows + 2)`. -- An update is `U_in`, and each window boundary it crosses adds a pool take, a shift of `max_windows` entries and a clear (`C_in`). -- `query_recent(t)` clones the active window and merges up to `t` closed windows into it, `C_in + min(t, max_windows)·M_in`. + The inner data describe the merged state, so a fold window's TopK has a bound only for a query that merges no closed window. +- Kll windows get no bound, because a seeded `KllConfig` builds every window from one seed. +- `params_for` is the inner `params_for`, around `TumblingParams::around`'s window length 1, 8 windows and a pool of 10. +- Memory: `size_of::() + S_c·size_of::() + S_f·size_of::() + A·(mem_in − size_of::())`, with `A = max(pool_initial_size, max_windows + 2)` the most sketches alive. + `S_c = max(4, 2·max_windows)` and `S_f` are the closed list's and the pool's slots. +- An update is `U_in`, and each window boundary it crosses adds a pool take, a push, a shift of `max_windows` entries and a clear, `U_in + b·(4 + 2·max_windows + C_in)`. +- `query_recent(t)` clones the active window and merges up to `t` closed windows into it, `C_in + min(t, max_windows)·(1 + M_in)`. - Kll and UnivMonQ windows ignore the weight. ### HashSketchEnsemble `src/sketch_framework/hashlayer.rs`. A list of Count-Min, Count Sketch and HyperLogLog sketches that share one hash per insert. +`EnsembleParams` lists the sketches, with the counter bytes `c` of each Count-Min and Count Sketch, and names `index`, the one the query and error functions read. - Each sketch keeps its own bound, and a statement about several adds their `δ`, because they share a hash. - When the list holds a Count-Min or Count Sketch, its HyperLogLog reads a different hash than a standalone one, so the two do not merge. -- Memory: `size_of::() + (number of sketches)·size_of::()` plus each boxed sketch's `mem_in`. -- An update costs the sum of the sketches' `U_in`, and computes one hash when the sketches' slices fit in it. +- Memory: `size_of::() + m·size_of::()` for `m` sketch slots, plus each boxed sketch's `size_of::() + d·w·c` and each inline HyperLogLog's `mem_in − size_of::()`, exact when the list was built at its length and its Count-Min and Count Sketch use `Vector2D` storage. +- An update reads each sketch and updates it, `Σ(1 + U_in)`, and computes one hash when the sketches' slices fit in it. +- A query reads the sketch at `index`, `1 + Q_in`. ## Tests - **Memory.** - Each memory function is at least `memory_bytes()` after building with the same inputs, and equal where its section gives an exact formula. + Each memory function is at least `memory_bytes()` after building with the same inputs, equal where its section gives an exact formula, and `None` without an input that has no upper bound. - **Ops.** - A build with an op-counting feature counts the ops one call performs, and a test checks the count is at most the formula, at two or more parameter sizes. + A build with the `op-count` feature counts the ops one call performs, and a test checks the count is at most the formula, at two or more parameter sizes. - **Error.** Over independent seeds, the fraction of estimates off by more than `ε` is tested against `δ` as a binomial, never on one fixed seed. A deterministic bound is tested on every run, and every test is mutation-checked: breaking the estimator turns it red. @@ -828,13 +906,6 @@ A change to an implementation updates its formulas in the same pull request and ## Migration -1. asap_sketchlib renames `estimated_memory_bytes` to `theoretical_memory_bytes`, and adds `SketchAnalytics`, `ErrorBound`, `ErrorMetric`, `Statistic::QuantileInterpolated`, the op-counting feature, the trait implementations with their tests, and `memory_bytes()` and `theoretical_memory_bytes` for the sketches and frameworks that lack them. +1. asap_sketchlib renames `estimated_memory_bytes` to `theoretical_memory_bytes`, and adds `SketchAnalytics`, `ErrorBound`, `ErrorMetric`, `StatisticKind::QuantileInterpolated`, the op-counting feature, the trait implementations with their tests, and `memory_bytes()` and `theoretical_memory_bytes` for the sketches and frameworks that lack them. 2. ASAPPlanner's `sketch_shape` and `accuracy/estimators` call these functions, and ASAPPlanner keeps combining errors across a plan and splitting its error budget. 3. ASAPPlanner's `analytical-resource-cost.md` links to this page in place of its summary table. - -## Open questions - -- Kll `rank()` returns a count, and at `k = 200`, `n = 10^6` it measures at 1.02 times the Quantile fit. - Should the Rank contract use a fit measured on asap_sketchlib, or the Quantile fit times 1.1? -- ASAPPlanner sizes CountSketch so each row is wrong with probability `1/3`, which gives `d = 47` at `δ = 0.01`, and ASAPv1 encodes at most 20 rows. - Should `params_for` choose the per-row probability that minimizes `d·w` with `d ≤ 20`, which gives `d = 5` and `w ≈ 9.5/ε²` at `δ = 0.01`? diff --git a/docs/tests.md b/docs/tests.md index 2272d958..001c397f 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -18,10 +18,16 @@ column enumerates every generated name with the parameter it was invoked with. cargo test ``` -Two feature gates hide tests from that command: `tests/e2e/experimental.rs` and +Three feature gates hide tests from that command: `tests/e2e/experimental.rs` and scattered `#[cfg(feature = "experimental")]` tests need -`cargo test --features experimental`, and the `runtime_tests` module in -`src/sketch_framework/octo.rs` needs `cargo test --features octo-runtime`. +`cargo test --features experimental`, the `runtime_tests` module in +`src/sketch_framework/octo.rs` needs `cargo test --features octo-runtime`, and +the op-count tests in `src/analytics/ops.rs` and `tests/analytics/` need +`--features op-count`. The `analytics` binary runs in full with: + +```bash +cargo test --test analytics --features "op-count experimental octo-runtime" +``` ## Sketches @@ -1006,7 +1012,7 @@ Wire tests: [`src/sketch_framework/univmon/wire.rs`](../src/sketch_framework/uni | `estimate_l2_sums_stratum_estimates_in_terminal_mode` | Terminal-mode L2 sums the disjoint strata. | After the same keys through `update_weighted_terminal`, verifies `estimate_l2()` equals `sqrt` of the summed per-layer `l2_squared()` within `1e-9`. | | `top_layer_complete_tracks_the_top_heap` | Top-layer completeness is readable. | On a one-layer heap-4 pyramid, verifies `top_layer_complete()` holds after 4 keys, fails after a fifth, and holds again after `clear()`. | | `error_bounds::estimate_l2_meets_its_documented_bound` | `estimate_l2` meets the `(ε, δ)` bound in the API documentation. | 150 seeded trials of 400 fresh random keys per shape (Zipf, uniform, single heavy key) into `5 x w` pyramids, `w` in `{64, 48}`: the count of trials with `estimate_l2()^2` off by more than `ε = 0.5` relative stays within `n·δ + 4σ + 1` for `δ = P(Bin(5, p) >= 3)`, `p = 2q/ε²`. `_long` (ignored) runs 3000 trials. | -| `error_bounds::estimate_cardinality_meets_its_documented_bound` | `estimate_cardinality` meets its joint error/completeness bound. | 60 seeded trials of 2000 fresh random keys per shape into `new(32, 3, 256, 8)` (`8 = ceil(log2(2000/32)) + 2`): incomplete top layers stay rare, joint events of completeness and error beyond `ε = 0.5` stay within the Chebyshev bound `2/(kε²)`; the ideal estimator (restoring the exact top count even on overflow) has variance under `1.35 F0²/k` and mean error within four standard errors of zero. `_long` (ignored) runs 2000 trials. | +| `error_bounds::estimate_cardinality_meets_its_documented_bound` | `estimate_cardinality` meets its joint error/completeness bound. | 60 seeded trials of 2000 fresh random keys per shape into `new(32, 3, 256, 8)` (`8 = ceil(log2(2000/32)) + 2`): incomplete top layers stay rare, joint events of completeness and error beyond `ε = 0.5` stay within the Chebyshev bound `1.35/(kε²)`; the ideal estimator (restoring the exact top count even on overflow) has variance under `1.35 F0²/k` and mean error within four standard errors of zero. `_long` (ignored) runs 2000 trials. | | `univmon_random_data_matches_ground_truth_within_configured_tolerance` | Random weighted updates keep every metric inside its own tolerance. | Over `10_000` seeded random weighted updates across 5,000 keys into `new(256, 6, 8192, 16)`, requires relative error against the exact truth map at or under `0.07` for cardinality and at or under `0.05` for `L1`, `L2`, and entropy. | | `univmon_layers_with_different_heap_loads_round_trip` | Each layer's heap contents and emitted order survive the round trip. | For an `new(8, 2, 16, 4)` pyramid fed 40 weighted keys, verifies the layers carry different heap loads, that every layer's length, capacity of `8`, and per-key counts match after a decode, and that the bytes re-serialize identically. | | `univmon_carries_update_mode_and_candidate_flags` | `update_mode` and `candidate_complete` are state, not derived. | For a terminal-only `new(2, 2, 8, 3)` pyramid fed 20 keys through `fast_insert`, verifies the payload carries `update_mode` `2`, that the decode preserves the candidate flags, cardinality, and entropy and re-serializes identically, and that forcing every flag true flips `top_layer_complete()` from `false` to `true`. | @@ -1053,7 +1059,7 @@ Wire tests: [`src/sketch_framework/univmon_two_tier/wire.rs`](../src/sketch_fram | `two_tier_estimate_l2_sums_stratum_estimates_in_terminal_mode` | Terminal-mode L2 sums the disjoint strata. | After the same keys through `update_weighted_terminal`, verifies `estimate_l2()` equals `sqrt` of the summed per-layer `l2_squared()` within `1e-9`. | | `two_tier_top_layer_complete_tracks_the_top_heap` | Top-layer completeness is readable. | On a one-layer heap-4 pyramid, verifies `top_layer_complete()` holds after 4 keys, fails after a fifth, and holds again after `clear()`. | | `two_tier_estimate_l2_meets_its_documented_bound` | `estimate_l2` meets its `(ε, δ)` bound. | 150 seeded trials of 400 fresh random Zipf-weighted keys into `UnivMonTwoTier::new(8, 1, 5, 64, 3, 16, 3)`: trials with `estimate_l2()^2` off by more than `ε = 0.5` relative stay within `n·δ + 4σ + 1` for `δ = P(Bin(5, 0.125) >= 3)`. `_long` (ignored) runs 3000 trials. | -| `two_tier_estimate_cardinality_meets_its_documented_bound` | `estimate_cardinality` meets its joint error/completeness bound. | 60 seeded trials of 2000 fresh random keys into a heap-32 pyramid with `3x256` elephant and `2x32` mouse layers, `8 = ceil(log2(2000/32)) + 2` deep: incomplete top layers stay rare, joint events of completeness and error beyond `ε = 0.5` stay within the Chebyshev `2/(kε²)` bound; the ideal estimator (restoring the exact top count) has variance under `2 F0²/k` and mean error within four standard errors of zero. `_long` (ignored) runs 2000 trials. | +| `two_tier_estimate_cardinality_meets_its_documented_bound` | `estimate_cardinality` meets its joint error/completeness bound. | 60 seeded trials of 2000 fresh random keys into a heap-32 pyramid with `3x256` elephant and `2x32` mouse layers, `8 = ceil(log2(2000/32)) + 2` deep: incomplete top layers stay rare, joint events of completeness and error beyond `ε = 0.5` stay within the Chebyshev `1.35/(kε²)` bound; the ideal estimator (restoring the exact top count) has variance under `1.35 F0²/k` and mean error within four standard errors of zero. `_long` (ignored) runs 2000 trials. | | `univmon_two_tier_round_trip_serialization` | The envelope frames a pyramid under kind_id `0x11 0x01`, and the bytes are stable across a round trip. | For `UnivMonTwoTier::new(4, 2, 3, 16, 2, 8, 4)` fed four weighted string keys, verifies the bytes open with the ASAPv1 magic, a `kind_id_len` of `2`, and `0x11 0x01`, that the metadata carries that layout and `key_type` `string`, and that the decode matches on `total_weight`, L1, L2, cardinality, entropy, and the candidate flags, re-serializing identically. | | `univmon_two_tier_retired_kind_decodes_and_recomputes_l2` | The retired `0x11 0x00` decodes, its `l2` dropped and recomputed. | Re-wraps the populated two-tier pyramid as a `0x11 0x00` payload with an all-zero `l2`, and verifies it decodes to the same `estimate_l2` and re-encodes to the `0x11 0x01` bytes. | | `univmon_two_tier_geometry_survives` | The two tiers are derived from the layer's position. | Verifies every decoded layer keeps its tier's dimensions - `3x16` for the two elephant layers and `2x8` for the mouse layers - its own seed index, and its counter array. | @@ -1110,6 +1116,7 @@ Wire tests: [`src/sketch_framework/univmon_q/wire.rs`](../src/sketch_framework/u | `recovered_heavy_item_improves_residual_cdf_accuracy` | Folding the recovered heavy hitter into the CDF beats the raw occurrence sample. | Over 20,000 values that are `42.0` nine times in ten against a diffuse `1_000 +` residual mod `10_000`, at a 10-level, 1024-wide, 128-candidate, 256-sample config across 32 source ids, each hashing at its own seed index `1..=32`, verifies `heavy_hitters(1)` names `42.0` every time, the mean assisted CDF error is below `0.85` times the raw occurrence-sample error, the mean rank error at the heavy value's `0.9` boundary is below `0.25` times the raw one, and the p95 assisted error is under `0.04`. | | `eight_way_merge_retains_multi_metric_accuracy` | An eight-way merge keeps quantile, cardinality, F2, and heavy-hitter accuracy together. | Round-robins 100,000 updates - 40% `42.0`, the rest a shifted multiplicative hash mod `20_000` - across eight sketches at a 10-level, 4096-wide, `cols_halving_period` `7`, depth-3, 512-candidate, 1024-sample config, merges them pairwise down to one, and verifies the quantile rank error at `{0.01, 0.1, 0.5, 0.9, 0.99}` is under `0.03`, `estimate_cardinality` and `estimate_f2` are within 10% of the exact values, and `heavy_hitters(1)` names `42.0`. | | `clear_retains_configuration` | `clear` empties the sketch without disturbing its configuration. | Verifies a cleared 32-candidate sketch reports `is_empty`, returns the same `config` it was built with, and answers `quantile(0.5)` with `None`. | +| `binary_heap_op_models_bound_std_push_and_pop` | The `BinaryHeap` push and pop op models bound std's accesses, and the pop model is tight. | Mirrors std's `push` and its `pop` (`sift_down_to_bottom`, then `sift_up`) step by step on random max-heaps of 1 to 199 entries, 50 heaps each, verifying the mirror leaves the same array as std, that its counted accesses are at most `3 + 2⌊log2 n⌋` per push and `7 + 5⌊log2 n⌋` per pop, and that some pop comes within 1 of the pop model. | ### NitroBatch @@ -1567,6 +1574,19 @@ Test file: [`src/asapv1/admission_rate.rs`](../src/asapv1/admission_rate.rs) | `a_delta_merges_only_at_the_state_rate_and_a_refused_one_changes_nothing` | `merge_delta` requires the delta's rate to equal the state's. | For HLL, Count-Min, Count Sketch and DDSketch, verifies a delta whose rate differs from the state's fails and leaves the state unchanged, and the same delta at the state's rate merges. | | `a_sketch_nested_in_another_kind_holds_no_rate` | A sketch inside another kind's state carries no `admission_rate`. | Verifies an `EhBucketSketch` of a `p = 0.5` Count-Min, Count Sketch, DDSketch or HLL fails to serialize while the `p = 1` one serializes, that a triple whose Count-Min descriptor carries the key fails to rebuild, that a Hydra of `p = 0.5` CM, CS or HLL counters fails to serialize, as does one with a plain prototype and one `p = 0.5` cell, and that a CountMinSketchWithHeap or CountSketchWithHeap whose matrix holds `p = 0.5` and an Elastic whose light layer holds `p = 0.5` each fail to serialize where the `p = 1` one serializes. | +### Analytics + +Test files: [`src/analytics/mod.rs`](../src/analytics/mod.rs), [`src/analytics/ops.rs`](../src/analytics/ops.rs) + +| test_name | test_description | what_is_tested | +| --- | --- | --- | +| `p_med_matches_hand_computed_binomial_tails` | `p_med(d, β)` is the binomial tail `P(Bin(d, β) >= ⌈d/2⌉)`. | Verifies `p_med(1, 0.25) = 0.25`, `p_med(3, 0.1) = 0.028` and `p_med(2, 0.5) = 0.75`, the last two to `1e-12`. | +| `max_row_failure_inverts_p_med` | `max_row_failure(d, δ)` is the largest `β` with `p_med(d, β) <= δ`. | For `d` in `{1, 3, 5, 9, 19}` and `δ` in `{0.1, 0.01, 1e-4}`, verifies `p_med(d, β) <= δ` at the returned `β` and `p_med(d, 1.001·β) > δ`. | +| `normal_quantile_hits_known_values` | `normal_quantile` and `normal_upper_quantile` return the standard normal quantile. | Verifies `z(0.995)` is `2.575829` and `z(0.975)` is `1.959964`, each to `1e-5`, and `z(0.5)` is `0` to `1e-6`; and that `normal_upper_quantile` at tails `0.005` and `5e-21` is `2.575829` and `9.336045`, each to `1e-5`. | +| `lg_and_floor_log2_round_the_documented_ways` | `lg` rounds `log2` up, `floor_log2` rounds it down, and `heap_update_ops` follows from the latter. | Verifies `lg(1) = 0`, `lg(5) = 3`, `lg(8) = 3`, `floor_log2(1) = 0`, `floor_log2(5) = 2` and `heap_update_ops(8) = 50`. | +| `count_sees_only_the_ops_inside_the_closure` | `ops::count` returns only the ops performed inside its closure. | Under `op-count`, adds 5 ops outside, then verifies `count` over `add(3)` and `add_sort(8)` returns `3 + 8·3`. | +| `add_sort_uses_the_ceiling_of_log2` | `add_sort(p)` counts `p·⌈log2 p⌉`, and nothing for one item. | Under `op-count`, verifies `add_sort(5)` counts 15 ops and `add_sort(1)` counts 0. | + ## 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. @@ -2195,7 +2215,7 @@ Test file: [`tests/heap_memory_bytes.rs`](../tests/heap_memory_bytes.rs) | test_name | test_description | what_is_tested | | --- | --- | --- | -| `estimated_memory_bytes_match_size_of_and_the_allocation` | The memory methods are the bytes an instance holds, counted by a global allocator. | Verifies a capacity-0 `TopKHeap` prices at `size_of` and a 1,024 heap above one entry and one digest per resident. For `i64` heaps of `u64` keys and `f64` heaps of 9-byte keys at `heap_size` in `{1, 3, 7, 8, 20, 100, 1024, 2000, 5000}`, verifies `memory_bytes` equals the allocation, which is at most `estimated_memory_bytes` after `heap_size` distinct keys and exactly it after 50 times as many, each evicting the root. Verifies the same for a regular `CountMinSketchWithHeap>` over `u64` keys and a fast-path `CountSketchWithHeap>` over 12-byte string keys at `1x1/1`, `3x64/7`, `5x2048/100` and `20x100/2000`, with weights rising so each new key evicts; that `CountMinF64` and a fast-path `CountSketch>` at `1x1`, `3x64`, `5x2048` and `64x1024` hold exactly `memory_bytes` after 1,000 keys; and that a default `UnivMonQ` holds exactly `memory_bytes` fresh, after 1,000 values and after 200,000 over 50,000 distinct. | +| `theoretical_memory_bytes_match_size_of_and_the_allocation` | The memory methods are the bytes an instance holds, counted by a global allocator. | Verifies a capacity-0 `TopKHeap` prices at `size_of` and a 1,024 heap above one entry and one digest per resident. For `i64` heaps of `u64` keys and `f64` heaps of 9-byte keys at `heap_size` in `{1, 3, 7, 8, 20, 100, 1024, 2000, 5000}`, verifies `memory_bytes` equals the allocation, which is at most `theoretical_memory_bytes` after `heap_size` distinct keys and exactly it after 50 times as many, each evicting the root. Verifies the same for a regular `CountMinSketchWithHeap>` over `u64` keys and a fast-path `CountSketchWithHeap>` over 12-byte string keys at `1x1/1`, `3x64/7`, `5x2048/100` and `20x100/2000`, with weights rising so each new key evicts; that `CountMinF64` and a fast-path `CountSketch>` at `1x1`, `3x64`, `5x2048` and `64x1024` hold exactly `memory_bytes` after 1,000 keys; and that a default `UnivMonQ` holds exactly `memory_bytes` fresh, after 1,000 values and after 200,000 over 50,000 distinct. | ### Accuracy-Probe Regressions @@ -2204,3 +2224,206 @@ Test file: [`tests/bug_verification.rs`](../tests/bug_verification.rs) | test_name | test_description | what_is_tested | | --- | --- | --- | | `count_sketch_with_l2_norm_f2_survives_beyond_i64_max` | `CountSketchWithL2Norm`'s hot-path L2 accumulation stays exact inside `i64` and saturates beyond it. | Builds `CountSketchWithL2Norm::::with_dimensions_and_seed_index(4, 2048, 7)` and calls `update_weighted(&DataInput::U32(1), 3_000_000_000)`, asserting `l2_squared()` matches the exact `9e18` to a relative `1e-12`; a second key `U32(2)` at the same count puts the true F2 at `1.8e19`, past `i64::MAX`, and `l2_squared()` must then equal `i64::MAX` (`9.223e18`) to a relative `1e-12` rather than wrapping to `0` or panicking in debug. | + +### Analytics: Counter Matrices + +The `analytics` binary checks [`docs/sketch_analytics.md`](sketch_analytics.md) against the sketches: `theoretical_memory_bytes` against `memory_bytes()`, the op formulas against the ops counted under the `op-count` feature, each error bound against the failure rate over independent trials (at most `allowed_failures(trials, δ)` = `⌊nδ + 4·sqrt(nδ(1-δ)) + 1⌋`), and `params_for` sizing against its own bound. Each trial draws fresh random keys from `rng(salt, trial)`. + +Test file: [`tests/analytics/count_matrix.rs`](../tests/analytics/count_matrix.rs) + +| test_name | test_description | what_is_tested | +| --- | --- | --- | +| `memory_is_exact_for_vector_storage` | Matrix memory is exact on `Vector2D` storage. | At `3x64` and `7x1024`, verifies `theoretical_memory_bytes` equals `memory_bytes()` for a regular-path `CountMinSketch>`, a fast-path `CountSketch>` and a `CountSketchWithL2Norm`. | +| `count_min_overcounts_by_at_most_epsilon_n_with_probability_one_minus_delta` | Count-Min never undercounts and overcounts by more than `ε·N` with probability at most `δ`. | At `3x64` on both hash paths, takes the `ItemFrequency` bound at the default `δ` and asserts its metric is `L1`. Over 800 trials of `mixed_keys(50 head keys, scale 64, 3,000 unit tail keys)`, queries head key `trial % 50`, asserts `estimate >= truth` on every trial, and counts trials with `estimate - truth > ε·N`; the count must be at most `allowed_failures(800, δ)`. | +| `fast_path_non_power_of_two_widths_collide_more_than_one_over_w` | On `FastPath` a width that is not a power of two collides at `(w + 3r)/(w + r)²`, which `q` and Count-Min's `ε` use. | For `w` in `{48, 40, 100}`, inserts 20,000 random unit keys into a one-row `FastPath` Count-Min, reads the pair collision rate as `(Σ estimates − K)/(K(K − 1))`, and verifies it is within 3% of `(w + 3r)/(w + r)²`, at most `collision_probability(w, true)` (with 3% slack), and that the `3xw` `ItemFrequency` bound's `ε` is `e·(w + 3r)/(w + r)²`. At `w = 48` verifies the rate exceeds `1.08/w` and `q = 9/(8w)`. | +| `fast_path_count_min_at_a_non_power_of_two_width_overcounts_by_at_most_epsilon_n` | A `FastPath` Count-Min at a non-power-of-two width keeps its bound. | At `2x48` on `FastPath`, over 600 trials of `mixed_keys(50, 64, 3000)`, queries head key `trial % 50`, asserts no undercount, and counts overcounts above `ε·N`; the count must be at most `allowed_failures(600, δ)`. | +| `count_sketch_is_off_by_at_most_epsilon_l2_with_probability_one_minus_delta` | Count Sketch is off by more than `ε·‖f‖₂` with probability at most `δ`. | At `3x256` on both hash paths and `δ = 0.05`, asserts the `ItemFrequency` bound's metric is `L2` and its `delta` is `0.05`. Over 800 trials of the same `mixed_keys` streams, counts trials where head key `trial % 50` is off by more than `ε·‖f‖₂`; the count must be at most `allowed_failures(800, 0.05)`. | +| `l2_norm_is_within_relative_epsilon_with_probability_one_minus_delta` | `CountSketchWithL2Norm::l2` is within relative `ε` with probability at most `δ` of failure. | At `5x1024`, asserts the `FrequencyL2` bound is `Relative` with `delta = p_med(5, 0.1)`. Over 800 trials of the same `mixed_keys` streams, counts trials with `abs(l2() - ‖f‖₂) > ε·‖f‖₂`; the count must be at most `allowed_failures(800, δ)`. | +| `count_min_sizing_meets_the_target_and_one_step_smaller_does_not` | Count-Min `params_for` meets `(ε, δ)` and is minimal in both dimensions. | For `(ε, δ)` in `(0.1, 0.1)`, `(0.01, 0.01)`, `(0.001, 1e-4)` on both hash paths, verifies the sized bound has `epsilon <= ε` and `delta <= δ`, and, through the same path's type, that one column fewer (half the columns on the fast path) gives `epsilon > ε` and that one row fewer has no bound at `δ`. | +| `count_sketch_sizing_meets_the_target_within_the_row_cap` | Count Sketch `params_for` meets `ε` at `δ` with an odd row count of at most 19. | For `(ε, δ)` in `(0.1, 0.1)`, `(0.05, 0.01)`, `(0.01, 1e-4)`, verifies the regular-path sizing has odd `rows <= 19` and `epsilon <= ε`, and the fast-path sizing has power-of-two `cols` and `epsilon <= ε`. At `(0.01, 0.01)` verifies `rows = 5` and `cols·1e-4` within `0.1` of `9.5`. | +| `l2_norm_sizing_meets_the_target_and_one_step_smaller_does_not` | `CountSketchWithL2Norm` `params_for` meets `(ε, δ)` and is minimal in both dimensions. | For `(ε, δ)` in `(0.2, 0.05)`, `(0.1, 0.01)`, `(0.05, 1e-3)`, verifies the sized `FrequencyL2` bound has `epsilon <= ε` and `delta <= δ`, that half the columns gives no bound or `epsilon > ε`, and that one row fewer has no bound at `δ`. | +| `l2_norm_item_sizing_takes_the_fewest_counters_that_fit_the_hash` | `CountSketchWithL2Norm` `ItemFrequency` sizing searches only shapes within 128 hash bits. | At `(0.02, 0.01)`, verifies the fast-path Count Sketch sizing needs more than 128 bits, and the `CountSketchWithL2Norm` sizing exists, needs at most 128, and meets `ε`. | +| `count_sketch_sizing_gives_a_column_for_huge_epsilon` | Regular-path Count Sketch sizing keeps at least one column. | For `ε` in `{1e100, 1e200, ∞}` at `δ = 0.01`, verifies `cols >= 1` and the bound meets `ε`. | +| `total_is_exact_and_unanswered_statistics_have_no_bound` | Count-Min's total is exact and statistics a sketch does not answer have no bound. | Verifies Count-Min's `Total` bound is `ErrorBound::EXACT`, Count Sketch has no `Total` bound, and Count-Min has no `Quantile` query-op count. | +| `ops::{count_min,count_sketch}_{regular,fast}` | Counted update, query and merge ops stay within the formulas. | Under `op-count`, one `matrix_ops!` body per invocation: `ops::count_min_regular`, `ops::count_min_fast` (each also checking `Total` through `total()`), `ops::count_sketch_regular`, `ops::count_sketch_fast`. At `3x64` and `7x1024`, loads 500 keys, then asserts the ops of one `update`, one `estimate`, each extra statistic and one `merge` of a clone are at most `theoretical_update_ops`, `theoretical_query_ops(ItemFrequency)`, that statistic's query ops and `theoretical_merge_ops`. | +| `ops::count_sketch_with_l2_norm` | `CountSketchWithL2Norm`'s counted ops stay within the formulas. | Under `op-count`, at `3x64` and `7x1024` after 500 unit updates, asserts one `update_weighted`, one `estimate` (`ItemFrequency`), one `l2()` (`FrequencyL2`) and one `merge` of a clone each take at most the formula's ops. | +| `experimental::memory_matches_the_storage` | `CountMinHll` and `MicroCm` memory is exact. | Under `experimental`, verifies `theoretical_memory_bytes` equals `memory_bytes()` for `CountMinHll` at `(2, 16, p4)` and `(3, 64, p8)`, and for a count-based `MicroCm` at `(2x16, t 4, c 2)` with 32 items per sub-window and `(4x64, t 12, c 3)` with 256. | +| `experimental::count_min_hll_ops` | `CountMinHll`'s counted ops stay within the formulas. | Under `experimental` and `op-count`, at both `CountMinHll` sizes after 300 updates over 17 keys, asserts one `update`, one `estimate` (`KeyDistinct`) and one `merge` of a clone each take at most the formula's ops. | +| `experimental::micro_cm_ops` | `MicroCm`'s mean update cost and its query and merge ops stay within the formulas. | Under `experimental` and `op-count`, at both `MicroCm` sizes, runs `per·(t + 2)` updates into each of two sketches and asserts the mean counted ops per update are at most `theoretical_update_ops`; then asserts one `estimate` and one `merge` take at most the formula's ops. | + +### Analytics: Heavy Hitters + +Test file: [`tests/analytics/heavy_hitters.rs`](../tests/analytics/heavy_hitters.rs) + +| test_name | test_description | what_is_tested | +| --- | --- | --- | +| `space_saving_memory_covers_the_summary` | The `SpaceSaving` memory bound covers the summary for `u64` and 16-byte string keys. | At capacity 16 and 256, streams `mixed_keys(40, 64, 4000)` as shuffled unit updates into a `u64`-key and a 16-byte `String`-key summary, merges the `u64` one with its clone, and verifies `theoretical_memory_bytes` (default data, then `key_bytes = 16`) is at least `memory_bytes()` for each. | +| `elastic_memory_is_exact_once_every_bucket_holds_a_flow` | `ElasticSketch` memory is bounded when empty and exact once every heavy bucket is occupied. | At `(16 buckets, 2x64 light)` and `(64 buckets, 4x256 light)` with `key_bytes = 16`, verifies the bound covers the empty sketch, then after 4,000 updates over 700 16-byte flows that no heavy bucket is vacant and the bound equals `memory_bytes()`. | +| `coco_memory_is_exact_once_every_bucket_holds_a_key` | `CocoSketch` memory is bounded at known `D` and exact once every bucket holds a key. | At `2x16` and `4x128`, seed 3, verifies the bound at `distinct = 300`, `key_bytes = 16` covers the sketch after 300 flows, then after 20,000 more that `recorded_flows()` counts `rows·cols` and the bound with unknown `D` equals `memory_bytes()`. | +| `space_saving_is_off_by_at_most_n_over_m_on_every_trial` | `SpaceSaving` estimates every key within `N/m`, deterministically. | At capacity 64, verifies the `ItemFrequency` bound is `L1` with `delta = 0` and equals the `TopK` bound at `δ = 0.01`. Over 100 trials of `mixed_keys(50, 64, 3000)` as shuffled unit updates, asserts every key's estimate and every `top_k(10)` count is within `ε·N` of its true count. | +| `elastic_overcounts_by_at_most_epsilon_n_with_probability_one_minus_delta` | `ElasticSketch` counts a never-evicted resident exactly, never undercounts, and overcounts by more than `ε·N` with probability at most `δ`. | At `(16 buckets, 3x64 light)`, verifies the `ItemFrequency` bound is `L1` with `delta = e^-3`. Over 600 trials of `mixed_keys(50, 64, 3000)` as shuffled unit updates of 16-byte flows, asserts every heavy resident without the eviction flag estimates its true count exactly; queries a head key on even trials and a tail key on odd ones, asserts no undercount, and counts trials with overcount above `ε·N`; the count must be at most `allowed_failures(600, δ)`. | +| `elastic_never_undercounts_any_key` | `ElasticSketch` never undercounts, through heavy-bucket evictions. | At `(4 buckets, 3x16384 light)`, over 20 trials of `mixed_keys(50, 64, 3000)` as shuffled unit updates, asserts every key's estimate is at least its true count. | +| `space_saving_sizing_meets_the_target_and_one_counter_fewer_does_not` | `SpaceSaving` `params_for` gives the fewest counters meeting `ε`. | For `ε` in `{0.1, 0.01, 0.003}` at `δ = 0`, verifies the sized bound meets `ε` and one counter fewer gives `epsilon > ε`. | +| `elastic_sizing_meets_the_target_and_one_step_smaller_does_not` | `ElasticSketch` `params_for` meets `(ε, δ)` and is minimal, with independent light rows capped at five. | For `(ε, δ)` in `(0.1, 0.1)`, `(0.01, 0.01)`, `(0.002, 0.007)`, verifies the sized bound meets `ε` and `δ`, one light column fewer misses `ε`, and one light row fewer has no bound at `δ`. Verifies `params_for(0.01, 1e-3)` is `None`, and that 8 light rows give `delta = e^-5` and no bound at `δ = 1e-3`. | +| `elastic_top_k_takes_delta_over_every_distinct_key` | `ElasticSketch`'s `TopK` bound needs `D` and union-bounds over it. | Verifies no `TopK` bound without `distinct`; at `D = 20`, verifies `params_for(TopK, 0.01, 0.2)` takes 5 light rows with `delta = 20·e^-5` and `epsilon <= 0.01`, and `params_for(TopK, 0.01, 0.1)` is `None`. | +| `space_saving_and_elastic_bounds_need_an_unmerged_state` | `SpaceSaving` and `ElasticSketch` give `ItemFrequency` and `TopK` bounds only for `merged = Some(false)`. | For both statistics, verifies `theoretical_error` and `params_for` give a bound with `merged = Some(false)` (Elastic at `D = 20`, sized by its own `params_for`) and `None` with `merged` unknown or `Some(true)`. | +| `unanswered_statistics_have_no_bound` | Statistics a sketch does not answer have no bound or op count. | Verifies `SpaceSaving` has no `Total` or `Quantile` error or query ops, `CocoSketch` has no `Total` or `Quantile` query ops and no `ItemFrequency`, `PartialKeySum` or `Total` error bound, and `ElasticSketch` has no `Total` bound. | +| `ops::space_saving_summary` | `SpaceSaving`'s counted ops stay within the formulas, and the costliest unit update hits the update formula exactly. | Under `op-count`, at capacity 16 and 256 with `top_k = capacity/2`, loads `mixed_keys(30, 64, 2000)`, then asserts an update of a held key, 20 new-key updates, one `estimate`, `top_k(capacity/2)`, `top_k(usize::MAX)` (default data) and one `merge` (`other_capacity = capacity`) each take at most the formula's ops. At capacity 2 after `[0, 1, 1, 1]`, verifies updating a new key costs exactly `theoretical_update_ops`. | +| `ops::space_saving_merge_covers_unequal_capacities` | A `SpaceSaving` merge between unequal capacities stays within the merge formula. | Under `op-count`, merges a full capacity-256 state into a full capacity-16 one and the reverse, each holding keys at distinct counts, and asserts the counted ops are at most `theoretical_merge_ops` at `other_capacity`; for 16 merging 256 also asserts they exceed the formula at `other_capacity = 16`. Verifies unknown `other_capacity` gives `INFINITY`. | +| `ops::space_saving_unit_update_after_a_merge` | A unit update of a merged `SpaceSaving` state stays within 47. | Under `op-count`, merges a full capacity-16 state into a capacity-256 state holding 8 keys, verifies the result is under-full with a positive `min_count`, and asserts 300 new-key and 8 held-key updates each take at most `theoretical_update_ops`. | +| `ops::elastic_sketch` | `ElasticSketch`'s counted ops stay within the formulas. | Under `op-count`, at both Elastic sizes after 3,000 updates into each of two sketches, asserts four updates (held and new flows), one `estimate`, one `top_k(5)` and one `merge` each take at most the formula's ops. | +| `ops::coco_sketch` | `CocoSketch`'s counted ops stay within the formulas. | Under `op-count`, at both Coco sizes after 2,000 updates into each of two sketches, asserts three weight-2 updates, one `estimate`, one `estimate_projected` on a 4-byte prefix (`PartialKeySum`) and one `merge` each take at most the formula's ops. | + +### Analytics: Top-K + +Test file: [`tests/analytics/top_k.rs`](../tests/analytics/top_k.rs) + +The `ItemFrequency` rows run `item_frequency_trials`: 800 trials of `mixed_keys(50 head keys, scale 2000, 3,000 unit tail keys)` inserted with their weights, querying head key `trial % 10`; a trial fails when the estimate is off by more than `ε` times `N` (an `L1` bound, which also asserts no undercount) or `‖f‖₂` (an `L2` bound), and failures must be at most `allowed_failures(800, δ)`. The `TopK` rows run `top_k_trials`: 300 trials of `mixed_keys(20 head keys, scale 300, 180 unit tail keys)` (`D = 200`) as a shuffled unit stream; a trial fails when any reported count is off by more than `ε` times the scale (an `L1` bound also asserts no reported count undercounts), and failures must be at most `allowed_failures(300, δ)`. + +| test_name | test_description | what_is_tested | +| --- | --- | --- | +| `count_min_with_heap_estimates_as_count_min` | `CountMinSketchWithHeap` point estimates meet Count-Min's bound. | `item_frequency_trials` (salt `50`) at `3x64`, heap 16, with the default-`δ` `ItemFrequency` bound. | +| `count_sketch_with_heap_estimates_as_count_sketch` | `CountSketchWithHeap` point estimates meet Count Sketch's bound. | `item_frequency_trials` (salt `51`) at `3x256`, heap 16, with the `ItemFrequency` bound at `δ = 0.05`. | +| `count_min_with_heap_reports_counts_within_the_top_k_bound` | `CountMinSketchWithHeap`'s reported counts meet the `TopK` bound. | At `7x256`, heap 16, with `D = 200`, verifies the `TopK` bound's `delta` is `200·e^-7`, then runs `top_k_trials` (salt `52`). | +| `count_sketch_with_heap_reports_counts_within_the_top_k_bound` | `CountSketchWithHeap`'s reported counts meet the `TopK` bound. | At `7x1024`, heap 16, with `D = 200`, verifies the `TopK` bound at `δ = 0.05` keeps `delta = 0.05`, then runs `top_k_trials` (salt `53`). | +| `count_min_with_space_saving_estimates_as_count_min` | `CountMinSketchWithSpaceSaving` point estimates meet Count-Min's bound. | `item_frequency_trials` (salt `54`) at `3x64`, capacity 16, with the default-`δ` `ItemFrequency` bound. | +| `count_sketch_with_space_saving_estimates_as_count_sketch` | `CountSketchWithSpaceSaving` point estimates meet Count Sketch's bound. | `item_frequency_trials` (salt `55`) at `3x256`, capacity 16, with the `ItemFrequency` bound at `δ = 0.05`. | +| `count_min_with_space_saving_reports_counts_within_the_top_k_bound` | `CountMinSketchWithSpaceSaving`'s reported counts meet the `TopK` bound. | At `5x256`, capacity 16, with `D = 200`, verifies the `TopK` bound's `delta` is `16·e^-5` (`min(m, D)` keys), then runs `top_k_trials` (salt `56`). | +| `count_sketch_with_space_saving_reports_counts_within_the_top_k_bound` | `CountSketchWithSpaceSaving`'s reported counts meet the `TopK` bound. | `top_k_trials` (salt `57`) at `5x1024`, capacity 16, `D = 200`, with the `TopK` bound at `δ = 0.05`. | +| `count_min_fold_estimates_as_count_min_at_full_width` | A folded Count-Min estimates with the full-width Count-Min bound. | `item_frequency_trials` (salt `58`) on `CountMinSketchFold` at 3 rows, `full_cols = 64`, `fold_level = 3`, heap 16, with the default-`δ` `ItemFrequency` bound. | +| `count_sketch_fold_estimates_as_count_sketch_at_full_width` | A folded Count Sketch estimates with the full-width Count Sketch bound. | `item_frequency_trials` (salt `59`) on `CountSketchFold` at 3 rows, `full_cols = 256`, `fold_level = 5`, heap 16, with the `ItemFrequency` bound at `δ = 0.05`. | +| `count_min_fold_reports_counts_within_the_top_k_bound` | A folded Count-Min's reported counts meet the `TopK` bound. | `top_k_trials` (salt `60`) at 7 rows, `full_cols = 256`, `fold_level = 3`, heap 16, `D = 200`, with the default-`δ` `TopK` bound. | +| `count_sketch_fold_reports_counts_within_the_top_k_bound` | A folded Count Sketch's reported counts meet the `TopK` bound. | `top_k_trials` (salt `61`) at 7 rows, `full_cols = 1024`, `fold_level = 3`, heap 16, `D = 200`, with the `TopK` bound at `δ = 0.05`. | +| `top_k_needs_d_and_a_count_min_product_below_one` | The heap variants' `TopK` bound needs `D`, the Space-Saving variants' takes `min(m, D)` with unknown `D` as `m`, and Count-Min's product must stay below one. | Verifies that without `distinct` neither heap variant has a `TopK` bound and `CountMinSketchWithHeap::params_for(TopK, 0.01, 0.01)` is `None`; that at the default 3 rows `D = 21` has no Count-Min `TopK` bound; that `CountMinSketchWithSpaceSaving` at `5x64`, capacity 8, `D = 200` has `delta = 8·e^-5`; and that without `distinct` both Space-Saving variants give the bound at `D = 200` and `params_for(TopK, 0.05, 0.01)` equals the sizing at `D = m`. | +| `fold_top_k_needs_a_state_merge_same_level_has_not_merged_into` | The folds give a `TopK` bound only for `merged = Some(false)`. | At 7 rows, `full_cols = 256`, `fold_level = 3`, heap 16, `D = 200`, verifies a `TopK` bound and sizing with `merged = Some(false)`, and with `merged` unknown or `Some(true)` no `TopK` bound or sizing for either fold while `ItemFrequency` keeps its bound and sizing. | +| `count_min_sizing_meets_the_target_and_one_step_smaller_does_not` | Count-Min-based `params_for` meets `(ε, δ)`, and the plain heap and fold sizings are minimal. | For `(ε, δ)` in `(0.1, 0.1)`, `(0.01, 0.01)`, `(0.001, 1e-4)`: the heap `ItemFrequency` sizing keeps the default `heap_size`, meets `ε` and `δ`, and one column or one row fewer misses; the heap `TopK` sizing at `D = 200` meets `ε` and one row fewer has no bound; the Space-Saving `TopK` sizing meets `ε`; the fold `ItemFrequency` sizing has power-of-two `full_cols`, meets `ε` and `δ`, and half the width misses `ε`. Verifies the fold sizing at `ε = 1e-5` is `None` (`W` is at most 65,536). | +| `count_sketch_sizing_meets_the_target` | Count-Sketch-based `params_for` meets `ε` at `δ`. | For `(ε, δ)` in `(0.1, 0.1)`, `(0.05, 0.01)`, `(0.02, 1e-3)`, verifies the heap `ItemFrequency` and `TopK` sizings, the Space-Saving `TopK` sizing, and the fold `ItemFrequency` and `TopK` sizings (power-of-two `full_cols`, odd `rows`) each meet `ε` at `D = 200`. Verifies the fold `ItemFrequency` sizing at `(1e-3, 0.01)` is `None`. | +| `fold_sizing_returns_parameters_new_accepts` | Fold sizing caps the fold level at `log2 W`. | For `ε` in `{0.01, 0.5, 1, 3, 1e6, 1e200, ∞}` at `δ = 0.01`, verifies both folds' `ItemFrequency` sizing has `fold_level = min(3, log2 full_cols)` and that `CountMinSketchFold::new` and `CountSketchFold::new` accept it. | +| `heap_variant_memory_covers_the_state` | The heap variants' memory bound covers the state at every step. | At `3x64`, heap 8 and `5x1024`, heap 64, streams `mixed_keys(40, 64, 3000)` as 16-byte `Str` keys into `CountMinSketchWithHeap` and `CountSketchWithHeap`, asserting after every update that `theoretical_memory_bytes` with `key_bytes = 16` is at least `memory_bytes()`; then verifies the `u64`-key bound covers a Count-Min heap variant merged with its clone. | +| `space_saving_variant_memory_covers_the_state` | The Space-Saving variants' memory bound covers the state. | At `3x64`, capacity 8 and `5x1024`, capacity 128, streams `mixed_keys(40, 64, 3000)` as 16-byte `Str` keys into both variants, merges the Count-Min one with its clone, and verifies `theoretical_memory_bytes` with `key_bytes = 16` is at least `memory_bytes()` for each. | +| `fold_memory_covers_the_state` | The fold memory bound covers the state with measured or unknown cell shapes. | At 3 rows, `full_cols = 1024`, `fold_level = 3`, heap 8 and 5 rows, `full_cols = 4096`, `fold_level = 5`, heap 32, streams `mixed_keys(40, 64, 2000)` as 16-byte `Str` keys into both folds and merges the Count-Min one with its clone. For each, verifies `theoretical_memory_bytes` is at least `memory_bytes()` both with the measured `max_cell_entries` and `collided_entries` and with them unknown. | +| `ops::heap_update_costs_at_most_h_k` | A `TopKHeap` update costs at most `h(k)` ops. | Under `op-count`, for `k` in `{1, 2, 3, 8, 15, 32, 100}`, runs 20,000 updates of random ids in `0..3k+5` with random counts in `0..1000` and asserts the worst counted update is at most `heap_update_ops(k)`. | +| `ops::a_full_descent_costs_exactly_h_k` | An update that sinks a new key from the root to a leaf costs exactly `h(k)`. | Under `op-count`, for `k = 2^j - 1`, `j` in `1..=7`, fills the heap with counts `1..=k`, then verifies each of 10 new keys with a count above every resident costs exactly `heap_update_ops(k)`. | +| `ops::a_clear_costs_two_per_resident` | Clearing a `TopKHeap` costs 2 ops per resident. | Under `op-count`, for `k` in `{1, 5, 64}`, fills the heap from `2k` keys and verifies `clear()` counts exactly `2k`. | +| `ops::count_{min,sketch}_with_heap` | The heap variants' counted ops stay within the formulas. | Under `op-count`, `heap_variant` with `est_count_min` (salt `71`) and `est_count_sketch` (salt `72`). At `3x64`, heap 8 and `5x1024`, heap 32, verifies `theoretical_update_ops` equals `d + est(d) + h(k)`, loads `mixed_keys(40, 64, 1000)`, and asserts 500 stream updates, 50 new keys at weight 1,000, one `estimate`, one heap read (`TopK`) and one `merge` each take at most the formula's ops. | +| `ops::count_{min,sketch}_with_space_saving` | The Space-Saving variants' counted ops stay within the formulas. | Under `op-count`, `space_saving_variant` (salts `73`, `74`). At `3x64`, capacity 16 and `5x1024`, capacity 64, after loading `mixed_keys(40, 64, 1000)`, asserts 500 stream updates, 50 new keys, one `estimate`, one `top_k(usize::MAX)`, one `TopK` read of an empty sketch at `distinct = 0` and one `merge` each take at most the formula's ops. | +| `ops::count_{min,sketch}_fold` | The folds' counted ops stay within the formulas, including `hierarchical_merge`. | Under `op-count`, `fold_variant` (salts `75`, `76`). At 3 rows, `full_cols = 1024`, `fold_level = 3`, heap 8 and 5 rows, `full_cols = 4096`, `fold_level = 6`, heap 32, takes the `FoldData` measured from the cells after the run, asserts 500 stream updates, 50 new keys, 50 `estimate`s, one heap read and one `merge` each take at most the formula's ops, and that the formulas with unknown cell shapes are at least the measured ones. Asserts `hierarchical_merge` of three states costs at most `dW + Σ(cells_i + 2·entries_i + k_i·(1 + est(d) + h(k)))`. | + +### Analytics: Cardinality and Membership + +Test file: [`tests/analytics/cardinality.rs`](../tests/analytics/cardinality.rs) + +| test_name | test_description | what_is_tested | +| --- | --- | --- | +| `hll_memory_is_exact` | HyperLogLog memory is exact. | Verifies `theoretical_memory_bytes` equals `memory_bytes()` for Classic at p8 and p14, Ertl at p12 and HIP at p8. | +| `kmv_memory_is_exact_for_known_distinct_and_bounds_unknown` | `Kmv` memory is exact at known `D` and bounded at unknown `D`. | For `k` in `{64, 3000}` and `D` in `{10, 2000, 20000}` distinct keys, verifies the bound at `distinct = D` equals `memory_bytes()` and the bound with unknown `D` is at least it. | +| `set_aggregator_memory_is_exact` | `SetAggregator` memory is exact at known `D` and key length, and bounded otherwise. | For `(D, key length)` in `(0, 8)`, `(5, 8)`, `(1000, 12)`, `(20000, 6)`, splits zero-padded keys across two sets and merges them, then verifies the bound equals `memory_bytes()` and the bound with default data is at least it. | +| `bloom_memory_is_exact` | Bloom memory is exact on both hash paths. | At `3x1024`, `7x65536` and `2x100`, verifies `theoretical_memory_bytes` equals `memory_bytes()` for `Bloom` and `Bloom`. | +| `hll_standard_errors_match_the_measured_relative_rmse` | Each HLL variant's standard error is its relative RMSE. | At p8 with `truth = 2,560` fresh keys per trial over 2,000 trials, verifies each bound is `Relative` with no `delta` and the measured relative RMSE is within 10% of `ε` for Classic, Ertl and HIP. | +| `classic_linear_counting_is_within_epsilon_with_probability_one_minus_delta` | Classic HLL in the linear-counting range is within `ε` with probability at least `1 - δ`. | At p12 with `max_distinct = 16` and `δ = 0.05`, verifies the contract is `classic_hll_linear_counting_collision_bound_v1` with `delta = 0.05`. Over 1,000 trials of 16 fresh keys, counts relative errors above `ε`; the count must be at most `allowed_failures(1000, 0.05)`. | +| `hll_contracts_follow_the_variant_and_the_conditions` | Each HLL variant and data condition selects its contract. | At p12, verifies Classic without `max_distinct` or at `max_distinct` 256 or 4096, and Ertl at 8, give `generic_hll_rse_only_no_confidence_v1` with no `delta`; Classic at 8 gives `delta = 0.01`; HIP gives `hip_hll_rse_only_no_confidence_v1` and has no merge op count. | +| `hll_standard_error_sizing_meets_the_target_and_one_step_smaller_does_not` | HLL standard-error `params_for` gives the smallest precision meeting `ε`. | For `ε` in `{0.1, 0.02, 0.005}`, verifies Ertl's sized precision meets `ε` and one lower misses, Classic sizes the same, and HIP's sized precision meets `ε` and one lower misses. | +| `classic_collision_sizing_meets_the_target_and_one_step_smaller_does_not` | Classic HLL collision-bound `params_for` gives the smallest precision meeting `(ε, δ)`. | For `(max_distinct, ε, δ)` in `(100, 0.001, 0.05)`, `(20, 0.001, 0.01)`, `(9, 0.005, 0.01)`, verifies the sized bound uses the collision contract with `epsilon <= ε`, and one precision lower has no bound, another contract, or `epsilon > ε`. | +| `classic_sizing_meets_its_own_bound` | Classic HLL `params_for` returns a precision whose own bound meets the request. | For `(max_distinct, ε, δ)` in `(1000, 0.01, 0.01)`, `(1000, 0.05, 0.01)`, `(256, 0.02, 0.01)`, `(100, 0.001, 0.05)`, `(5000, 0.05, 1e-3)`, verifies the bound at the sized precision has `epsilon <= ε` and, when it has a `delta`, `delta <= δ`. | +| `hll_standard_error_uses_flajolet_constants_below_256_registers` | The HLL standard error uses `β_m` for `m <= 128` and has no bound below `p = 4`. | Verifies Ertl has no bound at `p = 3` and `β_m/√m` for `β_m` of `1.106`, `1.070`, `1.054`, `1.046` and `1.04` at `p` 4 to 8, to `1e-12`; that Ertl sizes `ε = 0.5` at `p = 4` and `ε = 0.27` at `p = 5`, and Classic without `max_distinct` sizes `ε = 0.27` at `p = 5`. | +| `kmv_is_exact_below_k_and_within_epsilon_with_probability_one_minus_delta` | `Kmv` is exact below `k` and within relative `ε` with probability at least `1 - δ` above it. | At `k = 256`, verifies the bound at `D = 200` is `(0, 0)` and at `D = 5000`, `δ = 0.05` is `Relative`. Over 500 trials, inserts each of 200 and 5,000 fresh keys twice, asserts the 200-key estimate is exact, and counts 5,000-key relative errors above `ε`; the count must be at most `allowed_failures(500, 0.05)`. | +| `kmv_error_needs_a_valid_delta` | `Kmv` has no bound at a NaN or negative `δ`, or at `δ = 0` above `k`. | At `k = 256`, verifies no bound at `D = 5000` for `δ` in `{NaN, -0.1, 0}`, none at `D = 200` for `δ` in `{NaN, -0.1}`, `ε = 0` at `D = 200` and `δ = 0`, and a bound at `D = 5000` and `δ = 0.01`. | +| `kmv_sizing_meets_the_target_and_one_smaller_k_does_not` | `Kmv` `params_for` gives the smallest `k` meeting `(ε, δ)`. | Verifies `params_for(0.05, 0.01)` with unknown `D` is `k = 4323`; for `(ε, δ)` in `(0.05, 0.01)`, `(0.2, 0.1)`, `(0.01, 1e-4)`, `(1.5, 0.01)` the sized bound meets `ε` and `k - 1` misses (for `k > 2`); at `D = 500`, `params_for(0.01, 1e-4)` is `k = 501`. | +| `set_aggregator_is_exact` | `SetAggregator` answers distinct count and membership exactly. | Verifies the `Distinct` and `Membership` bounds are `ErrorBound::EXACT`; after 500 fresh keys plus 100 repeats, verifies 500 values held, every key contained and `"not a key"` absent. | +| `bloom_false_positive_rate_is_at_most_delta` | Bloom never misses a member and false-positives at rate at most `δ`. | At `4x1024` with `D = 650`, verifies the `Membership` bound has `epsilon = 0` and `delta` in `[0.03, 0.08)`. On both hash paths over 2,000 trials, inserts 650 fresh keys, asserts all read present, and counts trials where one fresh probe reads present; the count must be at most `allowed_failures(2000, δ)`. | +| `bloom_error_needs_d_and_a_power_of_two_width` | Bloom's bound needs `D`, a power-of-two width, and a `δ` at least its rate. | Verifies no bound at `4x1000`, none at `4x1024` without `D`, and none at `D = 10` for a requested `δ` of half the computed rate. | +| `bloom_sizing_meets_delta_with_the_fewest_bits` | Bloom `params_for` meets `δ` with the fewest bits over every slice count. | For `(D, δ)` in `(1000, 0.01)`, `(100000, 1e-4)`, `(10, 0.1)`, `(5000, 1e-6)`, verifies the sizing meets `δ`, half its width does not, and for every slice count up to `BLOOM_MAX_SLICES` no power-of-two width with fewer total bits meets `δ`. | +| `ops::hll_classic_and_ertl` | Classic and Ertl HLL counted ops stay within the formulas. | Under `op-count`, for Classic and Ertl at p8 and p12 after 5,000 keys into each of two overlapping sketches, asserts 100 updates, one `estimate` and one `merge` each take at most the formula's ops. | +| `ops::hll_hip` | HIP HLL counted ops stay within the formulas. | Under `op-count`, at p8 and p12, asserts each of 5,000 updates and one `estimate` take at most the formula's ops. | +| `ops::kmv` | `Kmv`'s counted ops stay within the formulas, filling and full. | Under `op-count`, for `k` in `{64, 1000}`, asserts each of `k` filling updates, four `update_with_hash` calls on a full sketch after 20,000 keys, one `estimate`, one `merge` and one merge into an empty sketch take at most the formula's ops. | +| `ops::set_aggregator` | `SetAggregator`'s counted ops stay within the formulas. | Under `op-count`, at `D` in `{10, 1000}`, asserts one `update`, one length read (`Distinct`), one `contains` (`Membership`) and one `merge` each take at most the formula's ops. | +| `ops::bloom` | Bloom's counted ops stay within the formulas on both hash paths. | Under `op-count`, at `3x1024` and `7x65536` after 500 keys, asserts one `update`, one `contains` and one `merge` of a clone on each hash path take at most the formula's ops. | + +### Analytics: Quantiles and Sampling + +Test file: [`tests/analytics/quantiles.rs`](../tests/analytics/quantiles.rs) + +The KLL accuracy rows run `kll_failures`: 1,000 trials of 100,000 fresh values in `[0, 1)` at `m = 8`, odd trials merging four independently seeded states over disjoint slices; a trial fails the quantile check when the true rank of `quantile(φ)` at a random `φ` in `[0.05, 0.95)` is off by more than `ε`, and the rank check when `rank(x)` at a random `x` is off by more than `ε·n`. Each count must be at most `allowed_failures(1000, 0.01)`. + +| test_name | test_description | what_is_tested | +| --- | --- | --- | +| `kll_memory_bounds_memory_bytes_through_merges` | `Kll`'s memory bound covers the state, merged or not. | At `(k, m)` in `(64, 8)`, `(200, 2)`, `(30, 20)`, `n` in `{0, 10, 300, 5000, 100000}` and 1 or 3 merged parts, verifies the bound at known `n` and at unknown `n` are each at least `memory_bytes()` after caching the CDF. | +| `kll_dynamic_memory_bounds_memory_bytes_through_merges` | `KllDynamic`'s memory bound covers the state, merged or not. | The same grid as the `Kll` row on `KllDynamic`, without a cached CDF. | +| `kll_quantile_and_rank_at_k_64_are_within_epsilon_with_probability_one_minus_delta` | `Kll` quantile and rank meet their default-`δ` bounds at `k = 64`. | `kll_failures` (salt `40`) at `k = 64`; verifies the `Quantile` bound's metric is `Rank` and the `Rank` bound's is `L1`. | +| `kll_quantile_and_rank_at_k_200_are_within_epsilon_with_probability_one_minus_delta` | `Kll` quantile and rank meet their default-`δ` bounds at `k = 200`. | `kll_failures` (salt `41`) at `k = 200`. | +| `kll_dynamic_quantile_and_rank_at_k_64_are_within_epsilon_with_probability_one_minus_delta` | `KllDynamic` quantile and rank meet their default-`δ` bounds at `k = 64`. | `kll_failures` (salt `50`) at `k = 64`. | +| `kll_dynamic_quantile_and_rank_at_k_200_are_within_epsilon_with_probability_one_minus_delta` | `KllDynamic` quantile and rank meet their default-`δ` bounds at `k = 200`. | `kll_failures` (salt `51`) at `k = 200`. | +| `kll_bounds_follow_the_fits_and_need_m_at_least_8` | KLL's bounds are the measured fits, scaled below `δ = 0.01`, and need `m >= 8`. | At `k = 200, m = 8`, for `Quantile` (`2.296/k^0.9723`, `kll_quantile_empirical_99_v1`) and `Rank` (`2.07/k^0.9646`, `kll_rank_measured_99_v1`), verifies the default bound equals the bound at `δ = 0.01` and the fit; `δ = 0.05` keeps `ε` and reports `delta = 0.05`; `δ = 0.001` uses the `_delta_scaled_v1` contract with `ε` scaled by `z(1 - 0.0005)/z(0.995)` and matches `KllDynamic`. Verifies no bound at `m` in `{1, 2, 7}` or `δ = 0`, a bound at `(5, 40000)`, a `Quantile` bound and no `Rank` bound at `(3, 9)`, and no `Distinct` bound. Verifies no `Rank` bound at `k = 31` and one at `k = 32`, and `s(1e-20)` is `9.336045/2.575829` to `1e-5`. | +| `kll_sizing_meets_the_target_and_one_smaller_k_does_not` | KLL `params_for` gives the smallest `k` at `m = 8` meeting `(ε, δ)`. | For `Quantile` and `Rank`, `ε` in `{0.05, 0.01, 0.002}` and `δ` in `{0.05, 0.01, 1e-4}`, verifies `m = 8`, the sized bound meets `ε`, `k - 1` misses, and `KllDynamic` sizes the same; `ε = 1e-5` gives `None`. `Rank` at `(0.5, 0.01)` sizes `k = 32`, whose bound meets `0.5`. | +| `ddsketch_quantile_is_within_alpha_relative_on_every_trial` | Every DDSketch quantile is within relative `α`, deterministically. | Over 200 trials of 2,000 log-uniform magnitudes in `[1e-3, 1e6)` (30% negative on half the trials) at `α` in `{0.01, 0.05}`, one third merged from a one-third/two-thirds split, verifies the bound is `Relative` with `delta = 0` and contract `ddsketch_relative_error_alpha_v1`, and that `get_value_at_quantile(φ)` for nine fixed `φ` and one random is within `ε·abs(truth)` of the `⌈φn⌉`-th value. | +| `ddsketch_interpolated_quantile_is_within_its_bound_on_every_trial` | Every interpolated DDSketch quantile is within relative `α` on one-signed values and within `α·A` on signed values. | Over 200 trials at `α` in `{0.01, 0.05}`, on half the trials 2,000 positive magnitudes in `[1e-3, 1e6)` (bound `Relative`, contract `ddsketch_interpolated_relative_alpha_v1`) and on the other half magnitudes in `[10^5.98, 1e6)`, 40% negative (bound absolute, contract `ddsketch_interpolated_absolute_alpha_v1`, `ε = α·max_magnitude`), all with `delta = 0`; verifies `quantile_interpolated(φ)` for the same `φ` set is within the bound of the linearly interpolated true value. | +| `ddsketch_interpolated_bound_needs_a_for_signed_or_unknown_values` | DDSketch's interpolated bound needs `A` unless the values share a sign. | At `α = 0.02`, verifies no interpolated bound with default data, `ε = 1.0` for signed data with `max_magnitude = 50`, `delta = 0` for `Quantile` even at a requested `δ = 0.1`, and no `Total`, `Rank` or `Sample` bound. | +| `ddsketch_sizing_meets_the_target` | DDSketch `params_for` hits `ε` exactly. | For `Quantile` with default data and `QuantileInterpolated` with positive data and with signed data at `max_magnitude = 200`, at `ε` in `{0.05, 0.01, 0.001}`, verifies the sized bound equals `ε` to `1e-12` relative. Verifies `Quantile` at `ε = 1.0` and signed `QuantileInterpolated` at `ε = 300` size `α = 0.5` with a bound meeting `ε`, and `Quantile` at a NaN `ε` and `QuantileInterpolated` with default data give `None`. | +| `ddsketch_bounds_need_alpha_in_range_and_indexable_magnitudes` | DDSketch has no bound outside `α` in (0, 1) or the indexable magnitudes. | At `α = 1e-9`, whose indexable magnitudes are about `0.0371` to `26.98`, verifies a `Quantile` bound for magnitudes `0.04` to `26`, none for a smallest magnitude of `0.03` or NaN or a largest of `28`, `-1` or NaN, and sizing at `ε = 1e-9` for a largest magnitude of `26` but not `28`. For `α` in `{0, 1, -0.5, NaN}`, verifies no bound and no memory. | +| `ddsketch_memory_bounds_memory_bytes` | DDSketch's memory bound covers the state, merged or not. | At `α` in `{0.01, 0.05}` and `(n, negative share)` in `(1, 0)`, `(500, 0)`, `(5000, 0.3)`, verifies the bound at the measured data and at default data are each at least `memory_bytes()`, single and merged. | +| `ops::kll` | `Kll`'s counted ops stay within the formulas. | Under `op-count`, `kll_ops` at `(k, m)` in `(64, 8)`, `(200, 8)`, `(32, 2)` and `n` in `{k/2, 10k, 50000}`: asserts `n` updates take at most `n` times the mean update formula, the unknown-`n` formulas are at least the known-`n` ones, one `quantile(0.3)` and one `rank(0.3)` stay within their query formulas, and one `merge` stays within the merge formula. | +| `ops::kll_dynamic` | `KllDynamic`'s counted ops stay within the formulas. | Under `op-count`, `kll_ops` on `KllDynamic` over the same grid. | +| `ops::ddsketch` | DDSketch's counted ops stay within the formulas. | Under `op-count`, at `α` in `{0.01, 0.05}` over 5,000 signed log-uniform values split into two states, asserts each update of `1.0`, `-2.5`, `0.0`, `1e-300`, `1e9`, `-1e-4`, one `get_value_at_quantile(1.0)`, one `quantile_interpolated(0.9999)`, one `merge` and one merge into an empty sketch take at most the formula's ops. | +| `uniform::memory_bounds_memory_bytes_through_merges` | `UniformSampling`'s memory bound covers the sample, merged or not. | Under `experimental`, at rate in `{0.1, 0.5, 1.0}` and `n` in `{1, 37, 1000, 20000}` split two-thirds/one-third across seeds 3 and 4, verifies the bound at each sampler's own `n` is at least `memory_bytes()` before and after the merge. | +| `uniform::sample_has_no_bound_and_total_is_not_a_readout` | `UniformSampling` has no error bound, and `Total` is not a query. | Under `experimental`, verifies `Sample` and `Total` have no bound and no `params_for`, `Sample` has a query-op count and `Total` has none. | +| `uniform::ops` | `UniformSampling`'s counted ops stay within the formulas. | Under `experimental` and `op-count`, at rate in `{0.1, 0.5}` and `n` in `{100, 3000}`, asserts each update stays within the update formula at the count so far, and one `samples()` and one `merge` stay within theirs. | + +### Analytics: UnivMon Family + +Test file: [`tests/analytics/univmon.rs`](../tests/analytics/univmon.rs) + +The `Distinct` rows run `distinct_trials`: 150 trials of `mixed_keys(20 head keys, scale 64, D - 20 unit tail keys)` at `D = 1000` and `δ = 0.3`; a trial fails when the top layer overflowed (`top_layer_complete()` is false) or the estimate is off by more than `ε·D`, and failures must be at most `allowed_failures(150, 0.3)`. The `FrequencyL2` rows run `l2_trials`: 300 trials of `mixed_keys(50, 64, 1000)` at the bound's fixed `δ`, counting `l2()` estimates off by more than relative `ε`, with failures at most `allowed_failures(300, δ)`. + +| test_name | test_description | what_is_tested | +| --- | --- | --- | +| `univmon_distinct_is_within_relative_epsilon_with_probability_one_minus_delta` | UnivMon's distinct count is within relative `ε` with probability at least `1 - δ`. | `distinct_trials` (salt `1`) at heap 256, `3x256`, 7 layers and `D = 4000`; verifies the bound is `Relative` with `ε < 0.15`. | +| `univmon_l2_is_within_relative_epsilon_with_probability_one_minus_delta` | UnivMon's L2 is within relative `ε` with probability at least `1 - δ`. | At heap 8, `5x1024`, 2 layers, verifies the `FrequencyL2` bound has `delta = p_med(5, 0.1)`, then runs `l2_trials` (salt `2`). | +| `two_tier_distinct_is_within_relative_epsilon_with_probability_one_minus_delta` | Two-tier UnivMon's distinct count is within relative `ε` with probability at least `1 - δ`. | `distinct_trials` (salt `3`) at heap 256, 2 elephant layers of `3x256`, mouse `3x64`, 7 layers and `D = 4000`. | +| `two_tier_l2_reads_layer_zero_dimensions` | Two-tier UnivMon's L2 bound takes layer 0's elephant dimensions. | At heap 8, 1 elephant layer of `5x1024`, mouse `1x16`, 2 layers, verifies `delta = p_med(5, 0.1)`, then runs `l2_trials` (salt `4`). | +| `univmon_total_is_exact_and_distinct_needs_d_and_room_above_psi` | UnivMon's total is exact, entropy and G-sum have no bound, and the distinct bound needs `D`, enough layers, `δ > ψ` and `k >= 8`. | With default params, verifies the `Total` bound has `ε = 0` and contract `univmon_unit_update_total`, and `estimate_l1()` is `5050` after weights `1..=100`; verifies no `FrequencyEntropy` or `GSum` bound, no `Distinct` bound without `D`, none at `D = 10^4` with 8 layers, and with 11 layers none at `δ = ψ = (e/4)^16` and `ε = sqrt(1.35/(32·(0.1 - ψ)))` at `δ = 0.1`; verifies no bound at heap 7. | +| `univmon_distinct_sizing_meets_the_target_and_one_step_smaller_does_not` | UnivMon `Distinct` `params_for` meets `(ε, δ)` and is minimal in heap size and layers. | At `D = 100000` for `(ε, δ)` in `(0.5, 0.3)`, `(0.2, 0.1)`, `(0.1, 0.05)` on both UnivMon and two-tier, verifies the sized bound meets `ε`, heap `k - 1` gives no bound or `epsilon > ε`, and one layer fewer gives no bound. Verifies no sizing without `D`, a sizing at `D = 2^25`, and none at `D = 2^40`. | +| `univmon_l2_sizing_meets_the_target` | UnivMon `FrequencyL2` `params_for` meets `(ε, δ)` and half its width does not. | For `(ε, δ)` in `(0.2, 0.05)`, `(0.1, 0.01)`, verifies the sized bound meets `ε` and `δ` and half the columns give no bound or `epsilon > ε`. | +| `pyramid_memory_covers_the_state` | The UnivMon and two-tier memory bounds cover the state, merged or not. | At two UnivMon sizes (heap 8, `3x64`, 4 layers; heap 32, `5x512`, 7 layers) and two two-tier sizes, loads `mixed_keys(30, 64, 3000)` and verifies the bound is at least `memory_bytes()` before and after merging a second loaded state. | +| `pyramid_ops::univmon` | UnivMon's mean update cost and its query and merge ops stay within the formulas. | Under `op-count`, `pyramid_ops` at both UnivMon sizes: after loading `mixed_keys(30, 64, 2000)`, asserts the mean counted ops over 2,000 random-key updates is at most the update formula, and one `estimate_l1` (`Total`), `estimate_l2`, `estimate_cardinality`, `estimate_entropy`, `estimate_g_sum(x²)` (`GSum`) and `merge` each stay within their formulas. | +| `pyramid_ops::univmon_two_tier` | Two-tier UnivMon's mean update cost and its query and merge ops stay within the formulas. | Under `op-count`, `pyramid_ops` at both two-tier sizes. | +| `univmonq_item_frequency_is_off_by_at_most_epsilon_l2_with_probability_one_minus_delta` | UnivMon-Q's point estimate is off by more than `ε·‖f‖₂` with probability at most `δ`. | At 2 layers, 3 rows, 128 top-layer columns, heap 16 and `δ = 0.2`, verifies the bound is `L2` with `ε = sqrt(1/(128·max_row_failure(3, δ)))`. Over 150 trials of `mixed_keys(30, 100, 12000)` as unit `f64` updates, asserts head value `trial % 2` carries more than `1.5·ε·‖f‖₂`, and counts trials where it is off by more than `ε·‖f‖₂`; the count must be at most `allowed_failures(150, 0.2)`. | +| `univmonq_quantile_rank_is_within_epsilon_n_with_probability_one_minus_delta` | UnivMon-Q's quantile on a diffuse stream is within rank `ε·n` with probability at least `1 - δ`. | At 4 layers, 3 rows, 64 top-layer columns, 256 ordered samples, `n = 3000`, `δ = 0.05`, verifies the `Quantile` bound is `Rank`. With `F̂₂ = 2n`, over 200 trials of 3,000 distinct values in `[0, 1)` arriving in increasing order (asserting `estimate_f2() <= 2n` and an empty heavy set), counts trials where the true rank of `quantile(q)`, `q = (trial % 9 + 1)/10`, is off by more than `ε·n`; the count must be at most `allowed_failures(200, 0.05)`. | +| `univmonq_rank_is_within_epsilon_n_with_probability_one_minus_delta` | UnivMon-Q's rank on a diffuse stream is within `ε·n` with probability at least `1 - δ`. | The same configuration and streams, verifying the `Rank` bound is `L1` and counting trials where `rank(x)` at a random `x` is off by more than `ε·n`; the count must be at most `allowed_failures(200, 0.05)`. | +| `univmonq_bounds_need_their_conditions` | UnivMon-Q's `Quantile` and `Rank` bounds need `n`, `F̂₂·M < n²` and ordered samples. | Verifies the bounds at `n = 10000` with `F̂₂ = 10000` have `ε = sqrt(ln(200)/512) + 1/n`, and exist at `F̂₂ = 300000` with 256 ordered samples but not 512; that they are `None` with default data, `F̂₂ = 10^8/256`, unknown `F̂₂`, unknown `n`, or zero ordered samples; that `n = 64` with `F̂₂ = 10` gives a wider `Rank` bound than `n = 10000`; and that `Total`, `FrequencyL2`, `Distinct`, `GSum`, `FrequencyEntropy` and `TopK` have no bound. | +| `univmonq_sizing_meets_the_target_and_one_step_smaller_does_not` | UnivMon-Q `params_for` meets `ε` and is minimal in width and ordered samples. | For `(ε, δ)` in `(0.1, 0.1)`, `(0.05, 0.01)`, `(0.01, 1e-4)`, verifies the `ItemFrequency` sizing meets `ε` and half its top-layer width misses, and the `Quantile` and `Rank` sizings are `None` with default data and at `n = F̂₂ = 2^40` meet `ε` and one ordered sample fewer misses. At `n = 10000` and `(0.1, 0.1)`, verifies `F̂₂ = 600000` sizes 151 ordered samples with a bound and `F̂₂ = 662252` sizes none. | +| `univmonq_memory_covers_the_state` | UnivMon-Q's memory bound covers the state, fresh and loaded. | At two configurations (3 layers, 32 columns, halving period 1, 3 rows, 32-bit counters, heap 8, 16 samples; 6 layers, 256 columns, halving period 2, 5 rows, 64-bit counters, heap 64, 300 samples), verifies the default-data bound is at least `memory_bytes()` fresh and after `mixed_keys(30, 64, 3000)` unit updates. | +| `univmonq_ops` | UnivMon-Q's counted ops stay within the formulas, including heap rescoring. | Under `op-count`, at both configurations after loading, asserts 200 random-value updates each stay within the update formula, and that the worst update over four passes of `pass·heap_size` values on a fresh sketch does too; asserts one `estimate_frequency` and one `merge` stay within their formulas and `Quantile` has no query-op count. | + +### Analytics: Frameworks + +Test file: [`tests/analytics/frameworks.rs`](../tests/analytics/frameworks.rs) + +| test_name | test_description | what_is_tested | +| --- | --- | --- | +| `hydra_memory_is_exact_for_count_min_cells` | Hydra memory over Count-Min cells is exact. | For a `3x64` grid of arity 2 with `2x32` cells and a `5x256` grid of arity 3 with `3x128` cells, after one update, verifies `theoretical_memory_bytes` with the schema's `label_bytes` equals `memory_bytes()`. | +| `hydra_count_min_frequency_is_within_epsilon_n_with_probability_one_minus_delta` | A Hydra subpopulation frequency over Count-Min cells is within `ε·N` with probability at least `1 - δ`. | On a `3x512` grid of arity 1 with `3x256` cells, verifies the `ItemFrequency` bound is `L1`. Over 200 trials of a Zipf head of 30 flows at scale 6,000 and 2,000 tail flows of weight 5, queries head flow `trial % 3`, asserts its weight is above `2·ε·N`, and counts estimates off by more than `ε·N`; the count must be at most `allowed_failures(200, δ)`. | +| `hydra_sizing_meets_the_target` | Hydra `ItemFrequency` `params_for` meets `(ε, δ)` at arity 2. | For `(ε, δ)` in `(0.2, 0.2)`, `(0.05, 0.01)`, `(0.01, 1e-4)`, verifies the sizing keeps arity 2 and meets `ε` and `δ`, and half its columns give `epsilon > ε/2`. | +| `hydra_sizing_meets_the_target_for_every_cell_kind` | Hydra `params_for` meets `(ε, δ)` for each cell statistic. | At arity 3 with `n = total_weight = 100000`, `group_weight = 20000`, `group_distinct = 5000`, `group_l2 = 8000`, `merged = Some(false)`, for `Quantile`, `Rank` and `FrequencyL2` at `(0.3, 0.1)` and `(0.2, 0.05)`, verifies a sizing exists and its bound meets `ε` and `δ`. | +| `hydra_hll_distinct_has_no_bound` | Hydra's HyperLogLog `Distinct` has no bound and no sizing. | On a `5x4096` grid of arity 1 with default HLL cells, `n = 100000` and `group_distinct = 5000`, verifies `theoretical_error` at `δ = 0.1` and `params_for(0.3, 0.1)` are `None`. | +| `eh_hll_distinct_has_no_bound` | The exponential histogram's HyperLogLog `Distinct` has no bound. | At `k = 8`, window 1,000, default HLL buckets and `window_arrivals = 1000`, verifies `theoretical_error(Distinct)` is `None` at the default `δ` and at `δ = 0.1`. | +| `hydra_kll_bounds_need_an_unmerged_hydra` | Hydra's Kll readouts have a bound only for `merged = Some(false)`. | Sizes a Kll Hydra for each of `Quantile` and `Rank` at `(0.3, 0.1)`, verifies its bound with `merged = Some(false)`, and none and no sizing with `merged` unknown or `Some(true)`; verifies a Count-Min Hydra keeps its `ItemFrequency` bound with `merged` unknown. | +| `hydra_ops_are_within_their_formulas` | Hydra's counted ops stay within the formulas. | Under `op-count`, at both grid sizes after 500 random updates into each of two Hydras, asserts one update, one `query_frequency` and one `query_group_total` on a first-column subpopulation, and one `merge` each take at most the formula's ops. | +| `eh_memory_bounds_memory_bytes` | The exponential histogram's memory bound covers the state. | For `(k, cell)` in `(4, 2x32)` and `(16, 3x64)` with window 300, over 1,000 timestamped updates of 50 keys into Count-Min and `CountSketchWithL2Norm` buckets, verifies the bound at the most buckets seen is at least `memory_bytes()`, and for Count-Min also the bound at `window_arrivals = 301`. | +| `eh_count_min_frequency_by_count_is_within_epsilon_n_w_with_probability_one_minus_delta` | A windowed Count-Min frequency is within `ε·N_W` with probability at least `1 - δ`. | At `k = 64`, window 1,000, `2x64` Count-Min buckets, verifies the `ItemFrequency` bound is `L1`. Over 200 trials ending at a random time in `[1000, 1600)`, each step the target key with probability 0.5 and a fresh key otherwise, counts trials where `query_interval` over the last window estimates the target off by more than `ε·(W + 1)`; the count must be at most `allowed_failures(200, δ)`. | +| `eh_ops_are_within_their_formulas` | The exponential histogram's mean update cost and its query ops stay within the formulas. | Under `op-count`, for both `(k, cell)` sizes, window 400, on Count-Min and `CountSketchWithL2Norm` buckets, asserts the mean counted ops over 1,500 updates is at most the update formula at the most buckets seen, and one window `query_interval` plus a point query (`ItemFrequency`) or `l2()` (`FrequencyL2`) stays within the query formula. | +| `nitro_memory_is_exact` | `NitroBatch` memory is exact. | At rate 0.25 over `3x64` and `5x512` fast-path Count-Min, verifies `theoretical_memory_bytes` equals `memory_bytes()`. | +| `nitro_count_min_frequency_is_within_epsilon_n_with_probability_one_minus_delta` | `NitroBatch` over Count-Min is within `ε·N` with probability at least `1 - δ`. | At rate 0.25, `3x64` and `δ = 0.5`, verifies the bound is `L1`. Over 300 trials of one key at weight 4,000 plus `mixed_keys(20, 400, 6000)` fed through `update_batch`, counts trials where `estimate_median` of that key is off by more than `ε·N`; the count must be at most `allowed_failures(300, 0.5)`. | +| `nitro_has_no_count_sketch_bound_and_needs_n` | `NitroBatch` has no Count Sketch bound, and its Count-Min bound needs `N` and room above `P_med`. | Verifies no `ItemFrequency` bound over Count Sketch, none over Count-Min without `total_weight`, and none at `δ = 0.01`. | +| `nitro_ops_are_within_their_formulas` | `NitroBatch`'s counted ops stay within the formulas. | Under `op-count`, at rate 0.1 over `3x64` and `5x512`, asserts a 200,000-key `update_batch` counts at most the mean formula plus `4·d·sqrt(n·q·(1 - q))`, and one `estimate_median` and one `merge` stay within their formulas. | +| `tumbling_memory_bounds_memory_bytes` | The tumbling window's memory bound covers the windows and the pool. | Over folds at `(2 rows, 64 cols, level 2, heap 8)` and `(3, 256, 3, 16)`, window size 10 and `(max_windows, pool)` in `(4, 2)`, `(3, 8)`, `(0, 0)`, after 400 updates of 100 keys, verifies the bound at `distinct = 100` is at least `memory_bytes()`. | +| `tumbling_error_is_the_inner_bound_and_kll_windows_have_none` | A tumbling window's error is its inner sketch's, and a KLL window has none. | Verifies the default fold window's `ItemFrequency` bound equals the fold's and a `TumblingWindow` has no `Quantile` bound. | +| `tumbling_ops_are_within_their_formulas` | The tumbling window's counted ops stay within the formulas. | Under `op-count`, at both fold sizes with 4 windows and a pool of 2, asserts each of 120 updates, each opening a new window, stays within the update formula at `distinct = 200`, and `query_recent(t)` for `t` in `{1, 3, 9}` stays within the query formula at `windows = t`. | +| `ensemble_memory_is_exact_and_each_sketch_keeps_its_bound` | The ensemble's memory is exact and each member keeps its own bound. | For two Count-Min members and one Ertl HLL at `3x64` and `4x1024`, verifies `theoretical_memory_bytes` equals `memory_bytes()` and the `ItemFrequency` bound at index 0 equals the Count-Min bound. | +| `ensemble_ops_are_within_their_formulas` | The ensemble's counted ops stay within the formulas. | Under `op-count`, at both sizes, asserts each of 300 updates, one `estimate` at index 1 and one `cardinality` at index 2 stay within their formulas. | +| `eh_univmon::eh_univmon_memory_bounds_memory_bytes` | `ExponentialHistogramUnivMon`'s memory bound covers the state. | Under `experimental`, at `(k, rows, cols, layers)` in `(4, 2, 8, 2)` and `(8, 2, 16, 2)`, window 1,500, over 3,000 updates of 400 keys, verifies the bound at the most UnivMon buckets, exact buckets and exact keys seen is at least `memory_bytes()` and that some bucket reached UnivMon. | +| `eh_univmon::eh_univmon_exact_l2_is_within_relative_epsilon_on_every_trial` | An exact-tier `ExponentialHistogramUnivMon` L2 is within relative `ε`, deterministically. | Under `experimental`, at `k = 200`, window 500, `3x1024`, 4 layers, verifies the `FrequencyL2` bound at `exact_query` has `delta = 0`. Over 60 trials ending at a random time in `[500, 1300)` over 50 keys, verifies no UnivMon bucket exists and `query_interval(...).estimate_l2()` is within `ε` relative of the true L2. | +| `eh_univmon::eh_univmon_ops_are_within_their_formulas` | `ExponentialHistogramUnivMon`'s mean update cost and its query ops stay within the formulas. | Under `experimental` and `op-count`, at both sizes, asserts the mean counted ops over 3,000 updates is at most the update formula at the observed bucket counts, that some bucket reached UnivMon, and that `query_interval` plus `estimate_l2` from three start times stays within the query formula. | +| `octo::octo_count_min_residual_is_within_epsilon_with_probability_one_minus_delta` | An unflushed OctoSketch Count-Min parent is within the residual bound with probability at least `1 - δ`. | Under `octo-runtime`, with 4 hash-by-key workers, threshold 60 and a `3x256` parent, runs workers and parent on one thread. Over 200 trials of `mixed_keys(20, 2000, 4000)`, counts trials where head key `trial % 3` is off by more than the `octo_error` bound's `ε`; the count must be at most `allowed_failures(200, δ)`. | +| `octo::octo_max_threshold_is_the_largest_a_worker_holds` | `τ_max` is the largest threshold a worker can hold. | Under `octo-runtime`, verifies `octo_max_threshold` is 31 at threshold 31 with adaptive band `(1, 8)`, 8 at threshold 4 with `(1, 8)`, 20 at threshold 4 with `(20, 8)`, 127 at threshold 4 with `(1, 500)`, and 31 at a fixed threshold 31. | +| `octo::octo_min_threshold_is_the_smallest_a_worker_holds` | `τ_min` is the smallest threshold a worker can hold, and the parent's writes per update use it. | Under `octo-runtime`, verifies `octo_min_threshold` is 2 at threshold 2 with adaptive floor 16 (and `octo_update_ops(3, ·)` gives `1.5` parent writes), 16 at threshold 31 with floor 16, 1 with floor 0, 127 at threshold 200 with floor 300, and 31 at a fixed threshold 31. | +| `octo::octo_bounds_follow_the_partition_and_the_flush` | OctoSketch's bound follows the partition, the parent and the flush. | Under `octo-runtime`, at `3x256`, 4 workers, threshold 60, `N = 10000`, `l2 = 500`, verifies round-robin adds `3·59` to the Count-Min `ε` and leaves Count Sketch unchanged; a flushed Count-Min parent has the plain Count-Min `ε`; a flushed Count-Min-with-heap parent has the unflushed Count-Min `ε`; `OctoParent::Other` has no bound; and `octo_memory_bytes` at 1,000 parent bytes and 768 worker-counter bytes exceeds `1000 + 4·768`. | diff --git a/src/analytics/cardinality.rs b/src/analytics/cardinality.rs new file mode 100644 index 00000000..f1dde85a --- /dev/null +++ b/src/analytics/cardinality.rs @@ -0,0 +1,644 @@ +//! Analytics of the cardinality and membership sketches: HyperLogLog, +//! HyperLogLogHip, Kmv, SetAggregator and Bloom. + +use super::{ + DEFAULT_DELTA, ErrorBound, ErrorMetric, HashPath, SketchAnalytics, StatisticKind, fixed_delta, + floor_log2, +}; +use crate::common::heap::hash_map_bytes_after_inserts; +use crate::sketches::bloom::partitioned_fpp; +use crate::sketches::hll::{HyperLogLogHipImpl, HyperLogLogImpl}; +use crate::sketches::kmv::KMV_DEFAULT_K; +use crate::{ + BLOOM_DEFAULT_COLS, BLOOM_DEFAULT_ROWS, BLOOM_MAX_BITS, BLOOM_MAX_SLICES, Bloom, Classic, + DefaultXxHasher, Ertl, HllRegisterStorage, HllRegistersP14, Kmv, SetAggregator, SketchHasher, +}; + +/// HyperLogLog precision `p`, with `m = 2^p` registers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HllParams { + pub precision: usize, +} + +impl Default for HllParams { + fn default() -> Self { + Self { + precision: HllRegistersP14::PRECISION, + } + } +} + +/// HyperLogLog's data inputs. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct HllData { + /// An upper bound on `D`. + pub max_distinct: Option, +} + +/// `D`, the data input of Kmv and Bloom. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct DistinctData { + /// `D`, the distinct keys inserted. + pub distinct: Option, +} + +const HLL_RSE: f64 = 1.04; +const GENERIC_HLL_CONTRACT: &str = "generic_hll_rse_only_no_confidence_v1"; +const CLASSIC_COLLISION_CONTRACT: &str = "classic_hll_linear_counting_collision_bound_v1"; +const HIP_CONTRACT: &str = "hip_hll_rse_only_no_confidence_v1"; + +/// The smallest precision Flajolet et al.'s standard error covers. +const HLL_MIN_PRECISION: usize = 4; + +/// Flajolet et al.'s `β_m` for `m = 16, 32, 64, 128`. +const HLL_SMALL_M_RSE: [f64; 4] = [1.106, 1.070, 1.054, 1.046]; + +/// Precisions and `max_distinct` the linear-counting collision bound covers. +const COLLISION_MIN_PRECISION: usize = 4; +const COLLISION_MAX_PRECISION: usize = 18; +const COLLISION_MAX_DISTINCT: u64 = 1 << 17; + +fn register_count(precision: usize) -> f64 { + 2f64.powi(precision as i32) +} + +/// A standard error `constant/√m`, with no `δ`. +fn rse_error(constant: f64, precision: usize, contract: &'static str) -> ErrorBound { + ErrorBound { + metric: ErrorMetric::Relative, + epsilon: constant / register_count(precision).sqrt(), + delta: None, + contract, + } +} + +/// `β_m` of HyperLogLog's standard error `β_m/√m`: the table for `p ≤ 7`, +/// 1.04 above, and `None` for `p < 4`. +fn hll_rse_constant(precision: usize) -> Option { + match precision { + p if p < HLL_MIN_PRECISION => None, + p if p < HLL_MIN_PRECISION + HLL_SMALL_M_RSE.len() => { + Some(HLL_SMALL_M_RSE[p - HLL_MIN_PRECISION]) + } + _ => Some(HLL_RSE), + } +} + +/// HyperLogLog's standard error `β_m/√m`; `None` for `p < 4`. +fn hll_rse_error(precision: usize) -> Option { + Some(rse_error( + hll_rse_constant(precision)?, + precision, + GENERIC_HLL_CONTRACT, + )) +} + +/// The smallest `p ≥ 4` with `β_m/√m ≤ ε`. +fn hll_rse_params_for(epsilon: f64) -> Option { + let mut precision = rse_params_for(HLL_RSE, epsilon)? + .precision + .max(HLL_MIN_PRECISION); + while hll_rse_error(precision)?.epsilon > epsilon { + precision += 1; + } + Some(HllParams { precision }) +} + +/// `p = ⌈2·log2(constant/ε)⌉`, at least 1; `None` past 63. +fn rse_params_for(constant: f64, epsilon: f64) -> Option { + if epsilon.is_nan() || epsilon <= 0.0 { + return None; + } + let precision = (2.0 * (constant / epsilon).log2()).ceil().max(1.0); + (precision <= 63.0).then_some(HllParams { + precision: precision as usize, + }) +} + +/// `ln(max over 2 ≤ D ≤ max_distinct of λ^t/t!)`, with `λ = D(D − 1)/(2m)` +/// and `t = ⌊εD⌋ + 1`, for `ε ≤ 1`; `ln_fact[i]` is `ln i!`. +fn collision_ln_delta(m: f64, max_distinct: usize, epsilon: f64, ln_fact: &[f64]) -> f64 { + (2..=max_distinct) + .map(|d| { + let d = d as f64; + let lambda = d * (d - 1.0) / (2.0 * m); + let t = (epsilon * d).floor() as usize + 1; + t as f64 * lambda.ln() - ln_fact[t] + }) + .fold(f64::NEG_INFINITY, f64::max) +} + +/// Classic's linear-counting bound: the smallest `ε` with +/// `max_distinct/(2(m − max_distinct)) ≤ ε ≤ 1` whose `δ` is at most +/// `delta`; `None` outside the conditions or when no such `ε` exists. +fn classic_collision_error( + precision: usize, + max_distinct: Option, + delta: Option, +) -> Option { + let max_distinct = max_distinct?; + let m = register_count(precision); + if !(COLLISION_MIN_PRECISION..=COLLISION_MAX_PRECISION).contains(&precision) + || max_distinct > COLLISION_MAX_DISTINCT + || max_distinct as f64 > m / 2.0 + { + return None; + } + let delta = delta.unwrap_or(DEFAULT_DELTA); + if delta.is_nan() || delta <= 0.0 { + return None; + } + let max_distinct = max_distinct as usize; + let floor = max_distinct as f64 / (2.0 * (m - max_distinct as f64)); + let mut ln_fact = vec![0.0; max_distinct + 3]; + for i in 1..ln_fact.len() { + ln_fact[i] = ln_fact[i - 1] + (i as f64).ln(); + } + let target = delta.ln(); + let meets = |epsilon: f64| collision_ln_delta(m, max_distinct, epsilon, &ln_fact) <= target; + let epsilon = if meets(floor) { + floor + } else if !meets(1.0) { + return None; + } else { + let (mut lo, mut hi) = (floor, 1.0f64); + for _ in 0..60 { + let mid = 0.5 * (lo + hi); + if meets(mid) { + hi = mid; + } else { + lo = mid; + } + } + hi + }; + Some(ErrorBound { + metric: ErrorMetric::Relative, + epsilon, + delta: Some(delta), + contract: CLASSIC_COLLISION_CONTRACT, + }) +} + +/// Classic's bound: the collision bound when it applies and its `ε` is at +/// most the standard error's, otherwise the standard error. +fn classic_error( + precision: usize, + max_distinct: Option, + delta: Option, +) -> Option { + let rse = hll_rse_error(precision)?; + Some( + classic_collision_error(precision, max_distinct, delta) + .filter(|bound| bound.epsilon <= rse.epsilon) + .unwrap_or(rse), + ) +} + +impl SketchAnalytics for HyperLogLogImpl { + type Params = HllParams; + type Data = HllData; + + fn theoretical_memory_bytes(params: &HllParams, _: &HllData) -> Option { + Some(Self::theoretical_memory_bytes(params.precision)) + } + + fn theoretical_update_ops(_: &HllParams, _: &HllData) -> f64 { + 1.0 + } + + fn theoretical_query_ops( + statistic: StatisticKind, + params: &HllParams, + _: &HllData, + ) -> Option { + (statistic == StatisticKind::Distinct).then(|| register_count(params.precision)) + } + + fn theoretical_merge_ops(params: &HllParams, _: &HllData) -> Option { + Some(2.0 * register_count(params.precision)) + } + + fn theoretical_error( + statistic: StatisticKind, + params: &HllParams, + data: &HllData, + delta: Option, + ) -> Option { + (statistic == StatisticKind::Distinct).then_some(())?; + classic_error(params.precision, data.max_distinct, delta) + } + + fn params_for( + statistic: StatisticKind, + epsilon: f64, + delta: f64, + data: &HllData, + ) -> Option { + (statistic == StatisticKind::Distinct).then_some(())?; + (COLLISION_MIN_PRECISION..=COLLISION_MAX_PRECISION) + .find(|&precision| { + classic_error(precision, data.max_distinct, Some(delta)).is_some_and(|bound| { + bound.contract == CLASSIC_COLLISION_CONTRACT && bound.epsilon <= epsilon + }) + }) + .map(|precision| HllParams { precision }) + .or_else(|| hll_rse_params_for(epsilon)) + } +} + +impl SketchAnalytics for HyperLogLogImpl { + type Params = HllParams; + type Data = HllData; + + fn theoretical_memory_bytes(params: &HllParams, _: &HllData) -> Option { + Some(Self::theoretical_memory_bytes(params.precision)) + } + + fn theoretical_update_ops(_: &HllParams, _: &HllData) -> f64 { + 1.0 + } + + fn theoretical_query_ops( + statistic: StatisticKind, + params: &HllParams, + _: &HllData, + ) -> Option { + (statistic == StatisticKind::Distinct) + .then(|| 2.0 * register_count(params.precision) + 66.0 - params.precision as f64) + } + + fn theoretical_merge_ops(params: &HllParams, _: &HllData) -> Option { + Some(2.0 * register_count(params.precision)) + } + + fn theoretical_error( + statistic: StatisticKind, + params: &HllParams, + _: &HllData, + _: Option, + ) -> Option { + (statistic == StatisticKind::Distinct).then_some(())?; + hll_rse_error(params.precision) + } + + fn params_for( + statistic: StatisticKind, + epsilon: f64, + _: f64, + _: &HllData, + ) -> Option { + (statistic == StatisticKind::Distinct).then_some(())?; + hll_rse_params_for(epsilon) + } +} + +impl SketchAnalytics for HyperLogLogHipImpl { + type Params = HllParams; + type Data = (); + + fn theoretical_memory_bytes(params: &HllParams, _: &()) -> Option { + Some(Self::theoretical_memory_bytes(params.precision)) + } + + fn theoretical_update_ops(_: &HllParams, _: &()) -> f64 { + 6.0 + } + + fn theoretical_query_ops(statistic: StatisticKind, _: &HllParams, _: &()) -> Option { + (statistic == StatisticKind::Distinct).then_some(1.0) + } + + fn theoretical_merge_ops(_: &HllParams, _: &()) -> Option { + None + } + + fn theoretical_error( + statistic: StatisticKind, + params: &HllParams, + _: &(), + _: Option, + ) -> Option { + (statistic == StatisticKind::Distinct).then(|| { + rse_error( + std::f64::consts::LN_2.sqrt(), + params.precision, + HIP_CONTRACT, + ) + }) + } + + fn params_for(statistic: StatisticKind, epsilon: f64, _: f64, _: &()) -> Option { + (statistic == StatisticKind::Distinct).then_some(())?; + rse_params_for(std::f64::consts::LN_2.sqrt(), epsilon) + } +} + +/// `k`, the hashes a Kmv sketch keeps. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct KmvParams { + pub k: usize, +} + +impl Default for KmvParams { + fn default() -> Self { + Self { k: KMV_DEFAULT_K } + } +} + +const KMV_CONTRACT: &str = "kmv_exact_below_k_chernoff_v1"; + +/// Kmv's `δ` at `ε`: Chernoff bounds on over- and underestimating by more +/// than `ε`, the second 0 for `ε ≥ 1`. +pub fn kmv_delta(epsilon: f64, k: usize) -> f64 { + let (e, n) = (epsilon, k.saturating_sub(1) as f64); + let over = (-e * e * n / ((1.0 + e) * (2.0 + e))).exp(); + let under = if e < 1.0 { + (-e * e * n / (2.0 * (1.0 - e))).exp() + } else { + 0.0 + }; + over + under +} + +/// The smallest `ε` with `kmv_delta(ε, k) ≤ delta`; `None` when none is. +fn kmv_epsilon(k: usize, delta: f64) -> Option { + let mut hi = 1.0f64; + while kmv_delta(hi, k) > delta { + hi *= 2.0; + if hi > 1e12 { + return None; + } + } + let mut lo = 0.0f64; + for _ in 0..100 { + let mid = 0.5 * (lo + hi); + if kmv_delta(mid, k) <= delta { + hi = mid; + } else { + lo = mid; + } + } + Some(hi) +} + +/// The smallest `k ≥ 2` with `kmv_delta(ε, k) ≤ delta`. +fn kmv_k(epsilon: f64, delta: f64) -> Option { + if epsilon.is_nan() || epsilon <= 0.0 || delta.is_nan() || delta <= 0.0 { + return None; + } + let mut hi = 2usize; + while kmv_delta(epsilon, hi) > delta { + hi = hi.checked_mul(2)?; + } + let mut lo = hi / 2; + if hi == 2 { + return Some(2); + } + while hi - lo > 1 { + let mid = lo + (hi - lo) / 2; + if kmv_delta(epsilon, mid) <= delta { + hi = mid; + } else { + lo = mid; + } + } + Some(hi) +} + +impl SketchAnalytics for Kmv { + type Params = KmvParams; + type Data = DistinctData; + + fn theoretical_memory_bytes(params: &KmvParams, data: &DistinctData) -> Option { + let distinct = data + .distinct + .map_or(usize::MAX, |d| usize::try_from(d).unwrap_or(usize::MAX)); + Some(Kmv::::theoretical_memory_bytes( + params.k, distinct, + )) + } + + fn theoretical_update_ops(params: &KmvParams, _: &DistinctData) -> f64 { + params.k as f64 + 3.0 + 6.0 * floor_log2(params.k) + } + + fn theoretical_query_ops( + statistic: StatisticKind, + _: &KmvParams, + _: &DistinctData, + ) -> Option { + (statistic == StatisticKind::Distinct).then_some(1.0) + } + + fn theoretical_merge_ops(params: &KmvParams, _: &DistinctData) -> Option { + let k = params.k as f64; + Some(k * (k + 4.0 + 6.0 * floor_log2(params.k))) + } + + fn theoretical_error( + statistic: StatisticKind, + params: &KmvParams, + data: &DistinctData, + delta: Option, + ) -> Option { + (statistic == StatisticKind::Distinct && params.k >= 2).then_some(())?; + if delta.is_some_and(|delta| delta.is_nan() || delta < 0.0) { + return None; + } + if data.distinct.is_some_and(|d| d < params.k as u64) { + return Some(ErrorBound { + metric: ErrorMetric::Relative, + epsilon: 0.0, + delta: Some(0.0), + contract: KMV_CONTRACT, + }); + } + let delta = delta.unwrap_or(DEFAULT_DELTA); + (delta > 0.0).then_some(())?; + Some(ErrorBound { + metric: ErrorMetric::Relative, + epsilon: kmv_epsilon(params.k, delta)?, + delta: Some(delta), + contract: KMV_CONTRACT, + }) + } + + fn params_for( + statistic: StatisticKind, + epsilon: f64, + delta: f64, + data: &DistinctData, + ) -> Option { + (statistic == StatisticKind::Distinct).then_some(())?; + let exact = data + .distinct + .and_then(|d| usize::try_from(d).ok()?.checked_add(1)) + .map(|k| k.max(2)); + let bounded = kmv_k(epsilon, delta); + let k = match (exact, bounded) { + (Some(a), Some(b)) => a.min(b), + (a, b) => a.or(b)?, + }; + Some(KmvParams { k }) + } +} + +impl SketchAnalytics for SetAggregator { + type Params = (); + type Data = SetData; + + fn theoretical_memory_bytes(_: &(), data: &SetData) -> Option { + let distinct = usize::try_from(data.distinct?).unwrap_or(usize::MAX); + let key_bytes = (distinct as f64 * data.mean_key_bytes?).ceil(); + Some( + size_of::() + .saturating_add(hash_map_bytes_after_inserts::(distinct)) + .saturating_add(if key_bytes >= usize::MAX as f64 { + usize::MAX + } else { + key_bytes as usize + }), + ) + } + + fn theoretical_update_ops(_: &(), _: &SetData) -> f64 { + 1.0 + } + + fn theoretical_query_ops(statistic: StatisticKind, _: &(), _: &SetData) -> Option { + match statistic { + StatisticKind::Distinct => Some(0.0), + StatisticKind::Membership => Some(1.0), + _ => None, + } + } + + fn theoretical_merge_ops(_: &(), data: &SetData) -> Option { + Some(data.distinct.map_or(f64::INFINITY, |d| 2.0 * d as f64)) + } + + fn theoretical_error( + statistic: StatisticKind, + _: &(), + _: &SetData, + _: Option, + ) -> Option { + matches!( + statistic, + StatisticKind::Distinct | StatisticKind::Membership + ) + .then_some(ErrorBound::EXACT) + } + + fn params_for(statistic: StatisticKind, _: f64, _: f64, _: &SetData) -> Option<()> { + matches!( + statistic, + StatisticKind::Distinct | StatisticKind::Membership + ) + .then_some(()) + } +} + +/// SetAggregator's data inputs. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct SetData { + /// `D`, the distinct keys. Unknown counts as unbounded. + pub distinct: Option, + /// `ℓ`, the mean key length in bytes. + pub mean_key_bytes: Option, +} + +/// `r` slices of `c` bits of a Bloom filter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BloomParams { + pub rows: usize, + pub cols: usize, +} + +impl Default for BloomParams { + fn default() -> Self { + Self { + rows: BLOOM_DEFAULT_ROWS, + cols: BLOOM_DEFAULT_COLS, + } + } +} + +const BLOOM_CONTRACT: &str = "bloom_partitioned_fpr_v1"; + +/// `(1 − (1 − 1/c)^D)^r`, and 0 for `D = 0`. +fn bloom_rate(rows: usize, cols: usize, distinct: u64) -> f64 { + if distinct == 0 { + 0.0 + } else { + partitioned_fpp(rows, cols, distinct as f64) + } +} + +impl SketchAnalytics for Bloom { + type Params = BloomParams; + type Data = DistinctData; + + fn theoretical_memory_bytes(params: &BloomParams, _: &DistinctData) -> Option { + Some(Self::theoretical_memory_bytes(params.rows, params.cols)) + } + + fn theoretical_update_ops(params: &BloomParams, _: &DistinctData) -> f64 { + params.rows as f64 + } + + fn theoretical_query_ops( + statistic: StatisticKind, + params: &BloomParams, + _: &DistinctData, + ) -> Option { + (statistic == StatisticKind::Membership).then_some(params.rows as f64) + } + + fn theoretical_merge_ops(params: &BloomParams, _: &DistinctData) -> Option { + Some(2.0 * (params.rows * params.cols.div_ceil(64)) as f64) + } + + fn theoretical_error( + statistic: StatisticKind, + params: &BloomParams, + data: &DistinctData, + delta: Option, + ) -> Option { + (statistic == StatisticKind::Membership + && params.cols.is_power_of_two() + && params.cols as u64 <= 1 << 32) + .then_some(())?; + let rate = bloom_rate(params.rows, params.cols, data.distinct?); + Some(ErrorBound { + metric: ErrorMetric::ABSOLUTE, + epsilon: 0.0, + delta: Some(fixed_delta(rate, delta)?), + contract: BLOOM_CONTRACT, + }) + } + + /// The `r ≤ BLOOM_MAX_SLICES` and power-of-two `c`, with `r·c` at most + /// `BLOOM_MAX_BITS`, that minimize `r·c` and meet `delta`. + fn params_for( + statistic: StatisticKind, + epsilon: f64, + delta: f64, + data: &DistinctData, + ) -> Option { + (statistic == StatisticKind::Membership && epsilon >= 0.0).then_some(())?; + let distinct = data.distinct?; + let mut best: Option = None; + for rows in 1..=BLOOM_MAX_SLICES { + let mut cols = 1usize; + while rows * cols <= BLOOM_MAX_BITS && bloom_rate(rows, cols, distinct) > delta { + cols *= 2; + } + if rows * cols <= BLOOM_MAX_BITS && best.is_none_or(|b| rows * cols < b.rows * b.cols) { + best = Some(BloomParams { rows, cols }); + } + } + best + } +} diff --git a/src/analytics/count_matrix.rs b/src/analytics/count_matrix.rs new file mode 100644 index 00000000..84cdad28 --- /dev/null +++ b/src/analytics/count_matrix.rs @@ -0,0 +1,522 @@ +//! Analytics of the counter matrices: Count-Min, Count Sketch, Count Sketch +//! with row L2 norms, and the experimental CountMinHll and MicroCm. + +use super::{ + DEFAULT_DELTA, ErrorBound, ErrorMetric, HashPath, SketchAnalytics, StatisticKind, + est_count_min, est_count_sketch, fixed_delta, is_positive, max_row_failure, p_med, +}; +use crate::common::structures::fixed_structure::{DEFAULT_COLS, DEFAULT_ROWS}; +use crate::{ + CountMinSketch, CountSketch, CountSketchWithL2Norm, MATRIX_MAX_ROWS, MatrixStorage, + SketchHasher, +}; +use std::f64::consts::E; + +/// `d` rows and `w` columns of a counter matrix. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MatrixParams { + pub rows: usize, + pub cols: usize, +} + +impl Default for MatrixParams { + fn default() -> Self { + Self { + rows: DEFAULT_ROWS, + cols: DEFAULT_COLS, + } + } +} + +pub(crate) const TWO_POW_MINUS_32: f64 = 1.0 / 4_294_967_296.0; + +/// `q`, the most probability two keys share a column of a `w`-column row. +pub fn collision_probability(cols: usize, fast_path: bool) -> f64 { + let w = cols as f64; + if !fast_path { + 1.0 / w + TWO_POW_MINUS_32 + } else if cols.is_power_of_two() { + 1.0 / w + } else { + 9.0 / (8.0 * w) + } +} + +/// Count-Min's `ε` at width `w`, widened on `FastPath` for a width that is +/// not a power of two. +fn count_min_epsilon(cols: usize, fast_path: bool) -> f64 { + let w = cols as f64; + if fast_path && !cols.is_power_of_two() { + let r = (cols.next_power_of_two() - cols) as f64; + E * (w + 3.0 * r) / ((w + r) * (w + r)) + } else { + E / w + } +} + +/// Count-Min sizing: `w = ⌈e/ε⌉`, rounded up to a power of two on `FastPath`, +/// and `d = max(1, ⌈ln(1/δ)⌉)`; `None` past `max_rows`. +pub(crate) fn count_min_params_for( + epsilon: f64, + delta: f64, + fast_path: bool, + max_rows: usize, +) -> Option { + if !is_positive(epsilon) || !is_positive(delta) { + return None; + } + let mut cols = (E / epsilon).ceil().max(1.0); + if !cols.is_finite() || cols > u32::MAX as f64 { + return None; + } + if fast_path { + cols = (cols as usize).next_power_of_two() as f64; + } + let rows = (1.0 / delta).ln().ceil().max(1.0) as usize; + (rows <= max_rows).then_some(MatrixParams { + rows, + cols: cols as usize, + }) +} + +/// Count-Min's ItemFrequency bound. +pub(crate) fn count_min_error( + params: &MatrixParams, + fast_path: bool, + delta: Option, + contract: &'static str, +) -> Option { + Some(ErrorBound { + metric: ErrorMetric::L1, + epsilon: count_min_epsilon(params.cols, fast_path), + delta: Some(fixed_delta((-(params.rows as f64)).exp(), delta)?), + contract, + }) +} + +/// Count Sketch's ItemFrequency bound at a chosen `δ`: the largest per-row +/// failure `β` with `P_med(d, β) ≤ δ`, and `ε = √(q/β)` by Chebyshev. +pub(crate) fn count_sketch_error( + rows: usize, + q: f64, + delta: Option, + contract: &'static str, +) -> Option { + let delta = delta.unwrap_or(DEFAULT_DELTA); + let beta = max_row_failure(rows, delta); + (beta > 0.0).then(|| ErrorBound { + metric: ErrorMetric::L2, + epsilon: (q / beta).sqrt(), + delta: Some(delta), + contract, + }) +} + +/// Count Sketch sizing: the odd `d ≤ 19` and width that meet `(ε, δ)` with +/// the fewest counters `d·w`. +pub(crate) fn count_sketch_params_for( + epsilon: f64, + delta: f64, + fast_path: bool, +) -> Option { + count_sketch_params_fitting(epsilon, delta, fast_path, |_| true) +} + +/// [`count_sketch_params_for`] over the shapes `fits` accepts. +fn count_sketch_params_fitting( + epsilon: f64, + delta: f64, + fast_path: bool, + fits: impl Fn(MatrixParams) -> bool, +) -> Option { + if !is_positive(epsilon) || !is_positive(delta) { + return None; + } + let mut best: Option = None; + for rows in (1..=MATRIX_MAX_ROWS).step_by(2) { + let target = max_row_failure(rows, delta) * epsilon * epsilon; + let cols = if fast_path { + if !is_positive(target) || 1.0 / target > (1u64 << 32) as f64 { + continue; + } + ((1.0 / target).ceil() as usize).next_power_of_two() + } else { + let room = target - TWO_POW_MINUS_32; + if !is_positive(room) || 1.0 / room > u32::MAX as f64 { + continue; + } + (1.0 / room).ceil().max(1.0) as usize + }; + let shape = MatrixParams { rows, cols }; + if fits(shape) && best.is_none_or(|b| rows * cols < b.rows * b.cols) { + best = Some(shape); + } + } + best +} + +pub(crate) const COUNT_MIN_CONTRACT: &str = "count_min_l1_markov_v1"; +pub(crate) const COUNT_SKETCH_CONTRACT: &str = "count_sketch_l2_median_chosen_row_rate_v1"; + +impl SketchAnalytics + for CountMinSketch +{ + type Params = MatrixParams; + type Data = (); + + fn theoretical_memory_bytes(params: &MatrixParams, _: &()) -> Option { + Some(CountMinSketch::::theoretical_memory_bytes( + params.rows, + params.cols, + )) + } + + fn theoretical_update_ops(params: &MatrixParams, _: &()) -> f64 { + params.rows as f64 + } + + fn theoretical_query_ops( + statistic: StatisticKind, + params: &MatrixParams, + _: &(), + ) -> Option { + match statistic { + StatisticKind::ItemFrequency => Some(est_count_min(params.rows)), + StatisticKind::Total => Some(params.cols as f64), + _ => None, + } + } + + fn theoretical_merge_ops(params: &MatrixParams, _: &()) -> Option { + Some(2.0 * (params.rows * params.cols) as f64) + } + + fn theoretical_error( + statistic: StatisticKind, + params: &MatrixParams, + _: &(), + delta: Option, + ) -> Option { + match statistic { + StatisticKind::ItemFrequency => { + count_min_error(params, Mode::FAST, delta, COUNT_MIN_CONTRACT) + } + StatisticKind::Total => Some(ErrorBound::EXACT), + _ => None, + } + } + + fn params_for( + statistic: StatisticKind, + epsilon: f64, + delta: f64, + _: &(), + ) -> Option { + match statistic { + StatisticKind::ItemFrequency => { + count_min_params_for(epsilon, delta, Mode::FAST, MATRIX_MAX_ROWS) + } + StatisticKind::Total => Some(MatrixParams::default()), + _ => None, + } + } +} + +impl SketchAnalytics + for CountSketch +{ + type Params = MatrixParams; + type Data = (); + + fn theoretical_memory_bytes(params: &MatrixParams, _: &()) -> Option { + Some(CountSketch::::theoretical_memory_bytes( + params.rows, + params.cols, + )) + } + + fn theoretical_update_ops(params: &MatrixParams, _: &()) -> f64 { + params.rows as f64 + } + + fn theoretical_query_ops( + statistic: StatisticKind, + params: &MatrixParams, + _: &(), + ) -> Option { + (statistic == StatisticKind::ItemFrequency).then(|| est_count_sketch(params.rows)) + } + + fn theoretical_merge_ops(params: &MatrixParams, _: &()) -> Option { + Some(2.0 * (params.rows * params.cols) as f64) + } + + fn theoretical_error( + statistic: StatisticKind, + params: &MatrixParams, + _: &(), + delta: Option, + ) -> Option { + (statistic == StatisticKind::ItemFrequency).then_some(())?; + let q = collision_probability(params.cols, Mode::FAST); + count_sketch_error(params.rows, q, delta, COUNT_SKETCH_CONTRACT) + } + + fn params_for( + statistic: StatisticKind, + epsilon: f64, + delta: f64, + _: &(), + ) -> Option { + (statistic == StatisticKind::ItemFrequency).then_some(())?; + count_sketch_params_for(epsilon, delta, Mode::FAST) + } +} + +/// The FrequencyL2 per-row failure probability, by Chebyshev's inequality. +const L2_ROW_FAILURE: f64 = 0.1; + +/// The most rows ASAPPlanner sizes a FrequencyL2 Count Sketch with. +pub(crate) const L2_MAX_ROWS: usize = 19; + +/// FrequencyL2 bound of a Count Sketch with row L2 norms on `FastPath`: +/// `Relative`, `ε = 1 − √(1 − √(20q))`, `δ = P_med(d, 0.1)`, when `20q < 1`. +pub(crate) fn l2_norm_error( + params: &MatrixParams, + delta: Option, + contract: &'static str, +) -> Option { + let spread = (20.0 * collision_probability(params.cols, true)).sqrt(); + (spread < 1.0).then_some(())?; + Some(ErrorBound { + metric: ErrorMetric::Relative, + epsilon: 1.0 - (1.0 - spread).sqrt(), + delta: Some(fixed_delta(p_med(params.rows, L2_ROW_FAILURE), delta)?), + contract, + }) +} + +/// FrequencyL2 sizing: the narrowest power-of-two width with +/// `20q ≤ (1 − (1 − ε)²)²` and the fewest rows with `P_med(d, 0.1) ≤ δ`. +pub(crate) fn l2_norm_params_for(epsilon: f64, delta: f64) -> Option { + if !is_positive(epsilon) || !is_positive(delta) { + return None; + } + let spread = 1.0 - (1.0 - epsilon.min(1.0)).powi(2); + let cols = (20.0 / (spread * spread)).ceil(); + if !cols.is_finite() || cols > (1u64 << 32) as f64 { + return None; + } + let cols = (cols as usize).next_power_of_two(); + let rows = (1..=L2_MAX_ROWS).find(|&d| p_med(d, L2_ROW_FAILURE) <= delta)?; + (rows * (cols.ilog2() as usize + 1) <= 128).then_some(MatrixParams { rows, cols }) +} + +pub(crate) const L2_NORM_CONTRACT: &str = "univmon_layer0_f2_median_chebyshev_binomial_v1"; + +impl SketchAnalytics for CountSketchWithL2Norm { + type Params = MatrixParams; + type Data = (); + + fn theoretical_memory_bytes(params: &MatrixParams, _: &()) -> Option { + Some( + CountSketchWithL2Norm::::theoretical_memory_bytes( + params.rows, + params.cols, + ), + ) + } + + fn theoretical_update_ops(params: &MatrixParams, _: &()) -> f64 { + 3.0 * params.rows as f64 + } + + fn theoretical_query_ops( + statistic: StatisticKind, + params: &MatrixParams, + _: &(), + ) -> Option { + matches!( + statistic, + StatisticKind::ItemFrequency | StatisticKind::FrequencyL2 + ) + .then(|| est_count_sketch(params.rows)) + } + + fn theoretical_merge_ops(params: &MatrixParams, _: &()) -> Option { + Some((3 * params.rows * params.cols + params.rows) as f64) + } + + fn theoretical_error( + statistic: StatisticKind, + params: &MatrixParams, + _: &(), + delta: Option, + ) -> Option { + match statistic { + StatisticKind::ItemFrequency => count_sketch_error( + params.rows, + collision_probability(params.cols, true), + delta, + COUNT_SKETCH_CONTRACT, + ), + StatisticKind::FrequencyL2 => l2_norm_error(params, delta, L2_NORM_CONTRACT), + _ => None, + } + } + + fn params_for( + statistic: StatisticKind, + epsilon: f64, + delta: f64, + _: &(), + ) -> Option { + let fits = |p: MatrixParams| { + CountSketchWithL2Norm::::check_hash_bits(p.rows, p.cols).is_ok() + }; + let params = match statistic { + StatisticKind::ItemFrequency => { + count_sketch_params_fitting(epsilon, delta, true, fits)? + } + StatisticKind::FrequencyL2 => l2_norm_params_for(epsilon, delta)?, + _ => return None, + }; + fits(params).then_some(params) + } +} + +#[cfg(feature = "experimental")] +pub use experimental::*; + +#[cfg(feature = "experimental")] +mod experimental { + use super::*; + use crate::{CountMinHll, MicroCm}; + + /// `d` rows, `w` columns and HyperLogLog precision `p` of a CountMinHll. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub struct CountMinHllParams { + pub rows: usize, + pub cols: usize, + pub precision: u32, + } + + impl Default for CountMinHllParams { + fn default() -> Self { + Self { + rows: DEFAULT_ROWS, + cols: DEFAULT_COLS, + precision: 8, + } + } + } + + impl SketchAnalytics for CountMinHll { + type Params = CountMinHllParams; + type Data = (); + + fn theoretical_memory_bytes(params: &CountMinHllParams, _: &()) -> Option { + Some(size_of::() + ((params.rows * params.cols) << params.precision)) + } + + fn theoretical_update_ops(params: &CountMinHllParams, _: &()) -> f64 { + params.rows as f64 + } + + fn theoretical_query_ops( + statistic: StatisticKind, + params: &CountMinHllParams, + _: &(), + ) -> Option { + (statistic == StatisticKind::KeyDistinct) + .then(|| (params.rows << params.precision) as f64) + } + + fn theoretical_merge_ops(params: &CountMinHllParams, _: &()) -> Option { + Some(((2 * params.rows * params.cols) << params.precision) as f64) + } + + fn theoretical_error( + _: StatisticKind, + _: &CountMinHllParams, + _: &(), + _: Option, + ) -> Option { + None + } + + fn params_for(_: StatisticKind, _: f64, _: f64, _: &()) -> Option { + None + } + } + + /// `d` rows, `w` columns, `T` sub-windows and zoom base `c` of a MicroCm. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub struct MicroCmParams { + pub rows: usize, + pub cols: usize, + pub t: usize, + pub c: u32, + } + + impl Default for MicroCmParams { + fn default() -> Self { + Self { + rows: 4, + cols: 1024, + t: 12, + c: 2, + } + } + } + + /// MicroCm's data input. + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] + pub struct MicroCmData { + /// `I`, the updates per sub-window of a count-based clock. + pub items_per_sub_window: Option, + } + + impl SketchAnalytics for MicroCm { + type Params = MicroCmParams; + type Data = MicroCmData; + + fn theoretical_memory_bytes(params: &MicroCmParams, _: &MicroCmData) -> Option { + Some(size_of::() + 4 * params.rows * params.cols * (params.t + 7).div_ceil(4)) + } + + fn theoretical_update_ops(params: &MicroCmParams, data: &MicroCmData) -> f64 { + let cells = (params.rows * params.cols) as f64; + let per_item = data.items_per_sub_window.unwrap_or(1).max(1) as f64; + 5.0 * params.rows as f64 + 5.0 * cells / per_item + } + + fn theoretical_query_ops( + statistic: StatisticKind, + params: &MicroCmParams, + _: &MicroCmData, + ) -> Option { + (statistic == StatisticKind::ItemFrequency) + .then(|| (params.rows * (2 * params.t + 3)) as f64) + } + + fn theoretical_merge_ops(params: &MicroCmParams, _: &MicroCmData) -> Option { + let rounds = if params.c == 2 { 2 } else { 1 }; + let pixels = params.t + 2; + Some((params.rows * params.cols * ((6 + 2 * rounds) * pixels + 7)) as f64) + } + + fn theoretical_error( + _: StatisticKind, + _: &MicroCmParams, + _: &MicroCmData, + _: Option, + ) -> Option { + None + } + + fn params_for(_: StatisticKind, _: f64, _: f64, _: &MicroCmData) -> Option { + None + } + } +} diff --git a/src/analytics/eh.rs b/src/analytics/eh.rs new file mode 100644 index 00000000..75a8112f --- /dev/null +++ b/src/analytics/eh.rs @@ -0,0 +1,451 @@ +//! Analytics of the exponential histograms: ExponentialHistogram, its +//! EhBucketSketch, and the experimental ExponentialHistogramUnivMon. + +use super::cardinality::HllParams; +use super::count_matrix::{L2_NORM_CONTRACT, MatrixParams, l2_norm_error}; +use super::frameworks::{Cell, CellData}; +use super::heavy_hitters::{CocoParams, ElasticParams}; +use super::quantiles::{DdParams, KllParams}; +use super::univmon::UnivMonParams; +use super::{ErrorBound, ErrorMetric, SketchAnalytics, StatisticKind, fixed_delta}; +use crate::sketch_framework::eh::EhBucket; +use crate::{EhBucketSketch, ExponentialHistogram}; + +/// An exponential histogram bucket's sketch kind and parameters. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum EhCell { + CountMin(MatrixParams), + Coco(CocoParams), + CountSketchWithL2Norm(MatrixParams), + CountSketch(MatrixParams), + DdSketch(DdParams), + Elastic(ElasticParams), + /// An `Ertl` HyperLogLog. + Hll(HllParams), + Kll(KllParams), + #[cfg(feature = "experimental")] + Uniform(super::UniformParams), + UnivMon(UnivMonParams), +} + +impl EhCell { + fn cell(&self) -> Cell<'_> { + match self { + Self::CountMin(p) => Cell::CountMin(p), + Self::Coco(p) => Cell::Coco(p), + Self::CountSketchWithL2Norm(p) => Cell::CountSketchWithL2Norm(p), + Self::CountSketch(p) => Cell::CountSketch(p), + Self::DdSketch(p) => Cell::DdSketch(p), + Self::Elastic(p) => Cell::Elastic(p), + Self::Hll(p) => Cell::HllErtl(p), + Self::Kll(p) => Cell::Kll(p), + #[cfg(feature = "experimental")] + Self::Uniform(p) => Cell::Uniform(p), + Self::UnivMon(p) => Cell::UnivMon(p), + } + } + + /// Whether buckets merge by L2 mass rather than by arrival count. + fn by_mass(&self) -> bool { + matches!(self, Self::CountSketchWithL2Norm(_) | Self::UnivMon(_)) + } +} + +impl Default for EhCell { + fn default() -> Self { + Self::CountMin(MatrixParams::default()) + } +} + +impl SketchAnalytics for EhBucketSketch { + type Params = EhCell; + type Data = CellData; + + fn theoretical_memory_bytes(params: &EhCell, data: &CellData) -> Option { + Some(size_of::().saturating_add(params.cell().heap(data)?)) + } + + fn theoretical_update_ops(params: &EhCell, data: &CellData) -> f64 { + params.cell().update(data) + } + + fn theoretical_query_ops( + statistic: StatisticKind, + params: &EhCell, + data: &CellData, + ) -> Option { + params.cell().query(statistic, data) + } + + fn theoretical_merge_ops(params: &EhCell, data: &CellData) -> Option { + params.cell().merge(data) + } + + fn theoretical_error( + statistic: StatisticKind, + params: &EhCell, + data: &CellData, + delta: Option, + ) -> Option { + params.cell().error(statistic, data, delta) + } + + fn params_for(_: StatisticKind, _: f64, _: f64, _: &CellData) -> Option { + None + } +} + +/// `k`, the window length and the bucket sketch of an ExponentialHistogram. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct EhParams { + pub k: usize, + pub window: u64, + pub cell: EhCell, +} + +impl Default for EhParams { + fn default() -> Self { + Self { + k: 2, + window: 1000, + cell: EhCell::default(), + } + } +} + +/// The data inputs of ExponentialHistogram. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct EhData { + /// `N_w`, the arrivals in the window. + pub window_arrivals: Option, + /// `G`, the most buckets the histogram keeps; from `N_w` when unknown + /// and merging by count. + pub buckets: Option, + /// Each bucket's inner data. + pub cell: CellData, +} + +const EH_COUNT_CONTRACT: &str = "eh_l1_boundary_bucket_v1"; +const EH_MASS_CONTRACT: &str = "eh_l2_boundary_bucket_mass_v1"; + +/// `ε_EH = 2/(⌈k/2⌉ + 1)`, the most of the window the oldest bucket holds +/// when merging by count. +pub fn eh_count_epsilon(k: usize) -> f64 { + 2.0 / (k.max(1).div_ceil(2) + 1) as f64 +} + +/// `ε_EH2 = ((1 + ε₂)/(1 − ε₂))·√(2/k)`, the boundary error when merging by +/// mass with an inner L2 estimate off by `ε₂`. +pub fn eh_mass_epsilon(k: usize, l2_epsilon: f64) -> f64 { + (1.0 + l2_epsilon) / (1.0 - l2_epsilon) * (2.0 / k.max(1) as f64).sqrt() +} + +/// `G`: the given bucket count, or `(⌊k/2⌋ + 2)(⌊log2(2N_w)⌋ + 1)` when +/// merging by count; `None` when neither is known. +fn bucket_bound(params: &EhParams, data: &EhData) -> Option { + data.buckets.or_else(|| { + if params.cell.by_mass() { + return None; + } + let arrivals = data.window_arrivals?.max(1); + let levels = (2 * arrivals).ilog2() as usize + 1; + Some((params.k / 2 + 2) * levels) + }) +} + +/// The slots a bucket list reaches holding at most `G + 1` buckets. +fn bucket_slots(buckets: usize) -> usize { + super::frameworks::vec_slots(0, buckets + 1) +} + +/// FrequencyL2 merging by mass: `ε = ε_EH2 + ε₂(1 + ε_EH2)`, +/// `δ = (G + 2)·δ_mass + δ_in` with `δ_in = δ_mass` from layer 0. +fn mass_error( + params: &EhParams, + data: &EhData, + layer0: &MatrixParams, + delta: Option, +) -> Option { + let mass = l2_norm_error(layer0, None, L2_NORM_CONTRACT)?; + let boundary = eh_mass_epsilon(params.k, mass.epsilon); + let buckets = bucket_bound(params, data)? as f64; + Some(ErrorBound { + metric: ErrorMetric::Relative, + epsilon: boundary + mass.epsilon * (1.0 + boundary), + delta: Some(fixed_delta((buckets + 3.0) * mass.delta?, delta)?), + contract: EH_MASS_CONTRACT, + }) +} + +impl SketchAnalytics for ExponentialHistogram { + type Params = EhParams; + type Data = EhData; + + fn theoretical_memory_bytes(params: &EhParams, data: &EhData) -> Option { + let buckets = bucket_bound(params, data)?; + let heap = params.cell.cell().heap(&data.cell)?; + Some( + size_of::() + .saturating_add(bucket_slots(buckets).saturating_mul(size_of::())) + .saturating_add((buckets + 1).saturating_mul(heap)), + ) + } + + /// Average per update. + fn theoretical_update_ops(params: &EhParams, data: &EhData) -> f64 { + let cell = params.cell.cell(); + let g = bucket_bound(params, data).map_or(f64::INFINITY, |g| g as f64); + let copy = cell.cells(&data.cell); + let merge = cell.merge(&data.cell).unwrap_or(f64::INFINITY); + let base = 2.0 * copy + cell.update(&data.cell) + merge; + if params.cell.by_mass() { + let estimate = cell + .query(StatisticKind::FrequencyL2, &data.cell) + .unwrap_or(f64::INFINITY); + base + 2.0 * estimate + 10.0 * g + 4.0 + } else { + base + 8.0 * g + 4.0 + } + } + + fn theoretical_query_ops( + statistic: StatisticKind, + params: &EhParams, + data: &EhData, + ) -> Option { + let cell = params.cell.cell(); + let readout = cell.query(statistic, &data.cell)?; + let g = bucket_bound(params, data)? as f64; + let merge = cell.merge(&data.cell)?; + Some(2.0 * g + 4.0 + cell.cells(&data.cell) + g * merge + readout) + } + + fn theoretical_merge_ops(_: &EhParams, _: &EhData) -> Option { + None + } + + fn theoretical_error( + statistic: StatisticKind, + params: &EhParams, + data: &EhData, + delta: Option, + ) -> Option { + let cell = params.cell.cell(); + match (¶ms.cell, statistic) { + (EhCell::CountMin(_) | EhCell::CountSketch(_), StatisticKind::ItemFrequency) => { + let boundary = eh_count_epsilon(params.k); + let inner = cell.error(statistic, &data.cell, delta)?; + Some(ErrorBound { + metric: ErrorMetric::L1, + epsilon: boundary + inner.epsilon * (1.0 + boundary), + delta: inner.delta, + contract: EH_COUNT_CONTRACT, + }) + } + (EhCell::CountSketchWithL2Norm(layer0), StatisticKind::FrequencyL2) => { + mass_error(params, data, layer0, delta) + } + (EhCell::UnivMon(p), StatisticKind::FrequencyL2) => { + let layer0 = MatrixParams { + rows: p.rows, + cols: p.cols, + }; + mass_error(params, data, &layer0, delta) + } + _ => None, + } + } + + fn params_for(_: StatisticKind, _: f64, _: f64, _: &EhData) -> Option { + None + } +} + +#[cfg(feature = "experimental")] +pub use eh_univmon::*; + +#[cfg(feature = "experimental")] +mod eh_univmon { + use super::*; + use crate::common::heap::hash_map_bytes_after_inserts; + use crate::sketch_framework::eh_univmon::{ + DEFAULT_POOL_INITIAL_SIZE, EhUnivMonExactBucket, EhUnivMonSketchBucket, + }; + use crate::{ExponentialHistogramUnivMon, OwnedDataInput, UnivMon}; + + /// `k`, the window length, the UnivMon shape and the pool's initial + /// size of an ExponentialHistogramUnivMon. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub struct EhUnivMonParams { + pub k: usize, + pub window: u64, + pub univmon: UnivMonParams, + pub pool_initial_size: usize, + } + + impl Default for EhUnivMonParams { + fn default() -> Self { + Self { + k: 2, + window: 1000, + univmon: UnivMonParams { + rows: 5, + cols: 2048, + layers: 8, + ..UnivMonParams::default() + }, + pool_initial_size: DEFAULT_POOL_INITIAL_SIZE, + } + } + } + + /// The data inputs of ExponentialHistogramUnivMon. + #[derive(Debug, Clone, Copy, Default, PartialEq)] + pub struct EhUnivMonData { + /// `G_s`, the most UnivMon buckets the histogram holds. + pub sketch_buckets: Option, + /// `G_e`, the most exact buckets it holds. + pub exact_buckets: Option, + /// `K`, the most keys its exact buckets hold together. + pub exact_keys: Option, + /// Bytes each key owns. + pub key_bytes: Option, + /// Whether a query reads only exact buckets. + pub exact_query: Option, + /// Each UnivMon bucket's inner data. + pub flow: crate::analytics::FlowData, + } + + const EXACT_CONTRACT: &str = "eh_univmon_exact_boundary_bucket_v1"; + const SKETCH_CONTRACT: &str = "eh_univmon_l2_boundary_bucket_v1"; + + fn inner(data: &EhUnivMonData) -> CellData { + CellData { + flow: data.flow, + ..CellData::default() + } + } + + /// `(G_s, G_e, K)`, infinite where unknown. + fn shape(data: &EhUnivMonData) -> (f64, f64, f64) { + let known = |v: Option| v.map_or(f64::INFINITY, |v| v as f64); + ( + known(data.sketch_buckets), + known(data.exact_buckets), + known(data.exact_keys), + ) + } + + impl SketchAnalytics for ExponentialHistogramUnivMon { + type Params = EhUnivMonParams; + type Data = EhUnivMonData; + + fn theoretical_memory_bytes( + params: &EhUnivMonParams, + data: &EhUnivMonData, + ) -> Option { + let (sketches, exact, keys) = + (data.sketch_buckets?, data.exact_buckets?, data.exact_keys?); + let cell = Cell::UnivMon(¶ms.univmon); + let heap = cell.heap(&inner(data))?; + let pooled = params.pool_initial_size.max(sketches + 1); + let free_slots = super::super::frameworks::vec_slots(params.pool_initial_size, pooled); + let map = hash_map_bytes_after_inserts::<(OwnedDataInput, i64)>(keys) + .saturating_add(keys.saturating_mul(data.key_bytes?)); + let bytes = size_of::() + .saturating_add( + bucket_slots(sketches).saturating_mul(size_of::()), + ) + .saturating_add( + bucket_slots(exact).saturating_mul(size_of::()), + ) + .saturating_add(free_slots.saturating_mul(size_of::())) + .saturating_add(pooled.saturating_mul(heap)) + .saturating_add(exact.saturating_mul(map)); + Some(bytes) + } + + /// Average per update over the hashes. + fn theoretical_update_ops(params: &EhUnivMonParams, data: &EhUnivMonData) -> f64 { + let cells = inner(data); + let cell = Cell::UnivMon(¶ms.univmon); + let (gs, ge, k) = shape(data); + let copy = cell.cells(&cells); + let update = cell.update(&cells); + let merge = cell.merge(&cells).unwrap_or(f64::INFINITY); + let e = crate::analytics::est_count_sketch(params.univmon.rows); + k * (1.0 + update + 2.0 * ge) + + 2.0 * ge * ge + + 10.0 * ge + + 2.0 * gs * gs + + gs * (2.0 * copy + merge + 3.0 * e + 7.0) + + 3.0 * e + + 6.0 + } + + fn theoretical_query_ops( + statistic: StatisticKind, + params: &EhUnivMonParams, + data: &EhUnivMonData, + ) -> Option { + let cells = inner(data); + let cell = Cell::UnivMon(¶ms.univmon); + let readout = cell.query(statistic, &cells)?; + let (gs, ge, k) = shape(data); + let merge = cell.merge(&cells)?; + let copy = cell.cells(&cells); + Some( + 2.0 * (gs + ge) + + 6.0 + + copy + + gs * (1.0 + merge) + + ge + + k * (3.0 + cell.update(&cells)) + + readout, + ) + } + + fn theoretical_merge_ops(_: &EhUnivMonParams, _: &EhUnivMonData) -> Option { + None + } + + fn theoretical_error( + statistic: StatisticKind, + params: &EhUnivMonParams, + data: &EhUnivMonData, + delta: Option, + ) -> Option { + (statistic == StatisticKind::FrequencyL2).then_some(())?; + if data.exact_query? { + return Some(ErrorBound { + metric: ErrorMetric::Relative, + epsilon: (2.0 / params.k.max(1) as f64).sqrt(), + delta: Some(fixed_delta(0.0, delta)?), + contract: EXACT_CONTRACT, + }); + } + let layer0 = MatrixParams { + rows: params.univmon.rows, + cols: params.univmon.cols, + }; + let mass = l2_norm_error(&layer0, None, L2_NORM_CONTRACT)?; + let boundary = eh_mass_epsilon(params.k, mass.epsilon); + let buckets = data.sketch_buckets? as f64; + Some(ErrorBound { + metric: ErrorMetric::Relative, + epsilon: boundary + mass.epsilon * (1.0 + boundary), + delta: Some(fixed_delta((buckets + 3.0) * mass.delta?, delta)?), + contract: SKETCH_CONTRACT, + }) + } + + fn params_for( + _: StatisticKind, + _: f64, + _: f64, + _: &EhUnivMonData, + ) -> Option { + None + } + } +} diff --git a/src/analytics/frameworks.rs b/src/analytics/frameworks.rs new file mode 100644 index 00000000..37c401cd --- /dev/null +++ b/src/analytics/frameworks.rs @@ -0,0 +1,860 @@ +//! Analytics of the frameworks that wrap other sketches: NitroBatch, +//! TumblingWindow, HashSketchEnsemble and Octo, and what Hydra and the +//! exponential histograms share with them. + +use super::cardinality::{HllData, HllParams}; +use super::count_matrix::{MatrixParams, collision_probability}; +use super::heavy_hitters::{CocoParams, ElasticParams, FlowData}; +use super::quantiles::{DdData, DdParams, KllParams, StreamData}; +use super::univmon::UnivMonParams; +use super::{ + DEFAULT_DELTA, ErrorBound, ErrorMetric, HashPath, SketchAnalytics, StatisticKind, + est_count_sketch, is_positive, p_med, +}; +use crate::common::numerical::NumericalValue; +use crate::sketches::hll::{HyperLogLogHipImpl, HyperLogLogImpl}; +use crate::{ + Classic, CocoSketch, CountMinSketch, CountMinSketchFold, CountSketch, CountSketchFold, + CountSketchWithL2Norm, DdSketch, ElasticSketch, Ertl, FastPath, HashSketchEnsemble, + HllRegisterStorage, HyperLogLog, HyperLogLogHip, Kll, MatrixStorage, NitroBatch, NitroTarget, + SketchHasher, TumblingWindow, TumblingWindowSketch, UnivMon, UnivMonQ, Vector2D, +}; +use std::f64::consts::E; + +/// What a framework reads of its inner sketch beyond [`SketchAnalytics`]. +pub trait InnerSketch: SketchAnalytics + Sized { + /// Bytes of one counter, register or item of a state with `params`. + fn cell_bytes(params: &Self::Params) -> usize; + + /// `C_in`: the cells a clone or clear of one state touches, its heap + /// bytes over [`Self::cell_bytes`]. + fn theoretical_cells(params: &Self::Params, data: &Self::Data) -> f64 { + match Self::theoretical_memory_bytes(params, data) { + Some(bytes) if bytes < usize::MAX => { + bytes.saturating_sub(size_of::()) as f64 / Self::cell_bytes(params) as f64 + } + _ => f64::INFINITY, + } + } + + /// Whether merging states cloned from one prototype or built from one + /// seed keeps the inner bound; Kll's does not. + const SHARED_SEED_MERGE_BOUND: bool = true; +} + +/// `C_in` of a live state: its heap bytes over its cell bytes. +pub(crate) fn heap_cells(memory_bytes: usize, inline_bytes: usize, cell_bytes: usize) -> usize { + memory_bytes.saturating_sub(inline_bytes) / cell_bytes.max(1) +} + +impl InnerSketch for CountMinSketch { + fn cell_bytes(_: &MatrixParams) -> usize { + size_of::() + } +} + +impl InnerSketch for CountSketch { + fn cell_bytes(_: &MatrixParams) -> usize { + size_of::() + } +} + +impl InnerSketch for CountSketchWithL2Norm { + fn cell_bytes(_: &MatrixParams) -> usize { + size_of::() + } +} + +impl InnerSketch for HyperLogLogImpl { + fn cell_bytes(_: &HllParams) -> usize { + 1 + } +} + +impl InnerSketch for HyperLogLogImpl { + fn cell_bytes(_: &HllParams) -> usize { + 1 + } +} + +impl InnerSketch for HyperLogLogHipImpl { + fn cell_bytes(_: &HllParams) -> usize { + 1 + } +} + +impl InnerSketch for Kll { + fn cell_bytes(_: &KllParams) -> usize { + size_of::() + } + + const SHARED_SEED_MERGE_BOUND: bool = false; +} + +impl InnerSketch for DdSketch { + fn cell_bytes(_: &DdParams) -> usize { + size_of::() + } +} + +impl InnerSketch for CocoSketch { + fn cell_bytes(_: &CocoParams) -> usize { + size_of::() + } +} + +impl InnerSketch for ElasticSketch { + fn cell_bytes(_: &ElasticParams) -> usize { + size_of::() + } +} + +impl InnerSketch for UnivMon { + fn cell_bytes(_: &UnivMonParams) -> usize { + size_of::() + } +} + +impl InnerSketch for CountMinSketchFold { + fn cell_bytes(_: &super::FoldParams) -> usize { + size_of::() + } +} + +impl InnerSketch for CountSketchFold { + fn cell_bytes(_: &super::FoldParams) -> usize { + size_of::() + } +} + +impl InnerSketch for UnivMonQ { + fn cell_bytes(config: &crate::UnivMonQConfig) -> usize { + usize::from(config.counter_bits / 8).max(1) + } +} + +#[cfg(feature = "experimental")] +impl InnerSketch for crate::UniformSampling { + fn cell_bytes(_: &super::UniformParams) -> usize { + crate::sketches::uniform::SAMPLE_ENTRY_BYTES + } +} + +/// The inner data inputs of a framework whose cells can be several kinds; +/// each kind reads its own field. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct CellData { + /// Kll and UniformSampling cells. + pub stream: StreamData, + /// Coco, Elastic and UnivMon cells. + pub flow: FlowData, + /// Classic and Ertl HyperLogLog cells. + pub hll: HllData, + /// DdSketch cells. + pub dd: DdData, +} + +pub(crate) type CmCell = CountMinSketch, FastPath>; +pub(crate) type CsCell = CountSketch, FastPath>; + +/// One cell kind and its parameters, for dispatching to its analytics. +#[derive(Debug, Clone, Copy)] +pub(crate) enum Cell<'a> { + CountMin(&'a MatrixParams), + CountSketch(&'a MatrixParams), + CountSketchWithL2Norm(&'a MatrixParams), + HllErtl(&'a HllParams), + HllClassic(&'a HllParams), + HllHip(&'a HllParams), + Kll(&'a KllParams), + DdSketch(&'a DdParams), + Coco(&'a CocoParams), + Elastic(&'a ElasticParams), + #[cfg(feature = "experimental")] + Uniform(&'a super::UniformParams), + UnivMon(&'a UnivMonParams), +} + +/// Runs `$body` with `$T` the cell's sketch type, `$p` its params and `$d` +/// its data. +macro_rules! on_cell { + ($cell:expr, $data:expr, |$T:ident, $p:ident, $d:ident| $body:expr) => {{ + let data: &CellData = $data; + match $cell { + Cell::CountMin($p) => { + type $T = CmCell; + let $d = &(); + $body + } + Cell::CountSketch($p) => { + type $T = CsCell; + let $d = &(); + $body + } + Cell::CountSketchWithL2Norm($p) => { + type $T = CountSketchWithL2Norm; + let $d = &(); + $body + } + Cell::HllErtl($p) => { + type $T = HyperLogLog; + let $d = &data.hll; + $body + } + Cell::HllClassic($p) => { + type $T = HyperLogLog; + let $d = &data.hll; + $body + } + Cell::HllHip($p) => { + type $T = HyperLogLogHip; + let $d = &(); + $body + } + Cell::Kll($p) => { + type $T = Kll; + let $d = &data.stream; + $body + } + Cell::DdSketch($p) => { + type $T = DdSketch; + let $d = &data.dd; + $body + } + Cell::Coco($p) => { + type $T = CocoSketch; + let $d = &data.flow; + $body + } + Cell::Elastic($p) => { + type $T = ElasticSketch; + let $d = &data.flow; + $body + } + #[cfg(feature = "experimental")] + Cell::Uniform($p) => { + type $T = crate::UniformSampling; + let $d = &data.stream; + $body + } + Cell::UnivMon($p) => { + type $T = UnivMon; + let $d = &data.flow; + $body + } + } + }}; +} + +impl Cell<'_> { + /// `mem_in − size_of::()`, the heap bytes of one state. + pub(crate) fn heap(self, data: &CellData) -> Option { + on_cell!(self, data, |T, p, d| { + ::theoretical_memory_bytes(p, d) + .map(|bytes| bytes.saturating_sub(size_of::())) + }) + } + + /// `U_in`. + pub(crate) fn update(self, data: &CellData) -> f64 { + on_cell!(self, data, |T, p, d| { + ::theoretical_update_ops(p, d) + }) + } + + /// `Q_in` of `statistic`. + pub(crate) fn query(self, statistic: StatisticKind, data: &CellData) -> Option { + on_cell!(self, data, |T, p, d| { + ::theoretical_query_ops(statistic, p, d) + }) + } + + /// `M_in`. + pub(crate) fn merge(self, data: &CellData) -> Option { + on_cell!(self, data, |T, p, d| { + ::theoretical_merge_ops(p, d) + }) + } + + /// `C_in`. + pub(crate) fn cells(self, data: &CellData) -> f64 { + on_cell!(self, data, |T, p, d| ::theoretical_cells( + p, d + )) + } + + /// `ε_in`, `δ_in` of `statistic`. + pub(crate) fn error( + self, + statistic: StatisticKind, + data: &CellData, + delta: Option, + ) -> Option { + on_cell!(self, data, |T, p, d| { + ::theoretical_error(statistic, p, d, delta) + }) + } +} + +/// The inner bound and total `δ` of a framework that fails with `own_delta` +/// plus `copies` inner failures: fixed at `own_delta + copies·δ_in` for a +/// fixed `δ_in`, else the inner gets `(δ − own_delta)/copies`. +pub(crate) fn split_delta( + own_delta: f64, + copies: f64, + delta: Option, + inner: impl Fn(Option) -> Option, +) -> Option<(ErrorBound, f64)> { + let at_default = inner(None)?; + // A fixed-δ contract refuses a `delta` below its own. + if inner(Some(at_default.delta? * 0.5)).is_none() { + let total = own_delta + copies * at_default.delta?; + return super::fixed_delta(total, delta).map(|total| (at_default, total)); + } + let total = delta.unwrap_or(DEFAULT_DELTA); + let room = (total - own_delta) / copies; + if !is_positive(room) { + return None; + } + inner(Some(room)).map(|bound| (bound, total)) +} + +// --------------------------------------------------------------------------- +// NitroBatch +// --------------------------------------------------------------------------- + +/// A Count-Min or Count Sketch a [`NitroBatch`] samples into. +pub trait NitroInner: InnerSketch + NitroTarget { + /// Whether the estimate is Count-Min's. + const COUNT_MIN: bool; +} + +impl NitroInner for CountMinSketch, FastPath, H> { + const COUNT_MIN: bool = true; +} + +impl NitroInner for CountSketch, FastPath, H> { + const COUNT_MIN: bool = false; +} + +/// Admission rate `ρ` and the inner matrix of a NitroBatch. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct NitroParams { + pub rate: f64, + pub inner: MatrixParams, +} + +impl Default for NitroParams { + fn default() -> Self { + Self { + rate: 0.1, + inner: MatrixParams::default(), + } + } +} + +/// `N`, the data input of NitroBatch. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct NitroData { + /// `N`, the updates in the batches. + pub total_weight: Option, +} + +const NITRO_CM_CONTRACT: &str = "nitro_batch_item_sampling_chebyshev_v1"; + +/// `V = (1 − ρ)/ρ + ρ·u(1 − u)`, `u` the fractional part of `1/ρ`. +pub fn nitro_sampling_variance(rate: f64) -> f64 { + let inverse = 1.0 / rate; + let u = inverse - inverse.floor(); + (1.0 - rate) / rate + rate * u * (1.0 - u) +} + +impl SketchAnalytics for NitroBatch { + type Params = NitroParams; + type Data = NitroData; + + fn theoretical_memory_bytes(params: &NitroParams, _: &NitroData) -> Option { + let inner = S::theoretical_memory_bytes(¶ms.inner, &())?; + Some(size_of::() + inner - size_of::()) + } + + /// Average per update of a batch. + fn theoretical_update_ops(params: &NitroParams, _: &NitroData) -> f64 { + params.rate * params.inner.rows as f64 + } + + fn theoretical_query_ops( + statistic: StatisticKind, + params: &NitroParams, + _: &NitroData, + ) -> Option { + (statistic == StatisticKind::ItemFrequency).then(|| est_count_sketch(params.inner.rows)) + } + + fn theoretical_merge_ops(params: &NitroParams, _: &NitroData) -> Option { + S::theoretical_merge_ops(¶ms.inner, &()) + } + + fn theoretical_error( + statistic: StatisticKind, + params: &NitroParams, + data: &NitroData, + delta: Option, + ) -> Option { + (statistic == StatisticKind::ItemFrequency && S::COUNT_MIN).then_some(())?; + let n = data.total_weight? as f64; + let rows = params.inner.rows; + let w = params.inner.cols as f64; + let delta = delta.unwrap_or(DEFAULT_DELTA); + let collisions = p_med(rows, collision_probability(params.inner.cols, true) * w / E); + let room = delta - collisions; + (room > 0.0 && n > 0.0).then_some(())?; + let sampling = (2.0 * nitro_sampling_variance(params.rate) / (n * room)).sqrt(); + let inner = E / w; + Some(ErrorBound { + metric: ErrorMetric::L1, + epsilon: sampling + inner * (1.0 + sampling), + delta: Some(delta), + contract: NITRO_CM_CONTRACT, + }) + } + + fn params_for(_: StatisticKind, _: f64, _: f64, _: &NitroData) -> Option { + None + } +} + +// --------------------------------------------------------------------------- +// TumblingWindow +// --------------------------------------------------------------------------- + +/// The window length, retained windows, pool size and inner parameters of a +/// TumblingWindow. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TumblingParams

{ + pub window_size: u64, + pub max_windows: usize, + pub pool_initial_size: usize, + pub inner: P, +} + +impl

TumblingParams

{ + /// Window length 1, 8 retained windows and a pool of 10 around `inner`. + pub fn around(inner: P) -> Self { + Self { + window_size: 1, + max_windows: 8, + pool_initial_size: 10, + inner, + } + } +} + +impl Default for TumblingParams

{ + fn default() -> Self { + Self::around(P::default()) + } +} + +/// The data inputs of TumblingWindow. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct TumblingData { + /// `t`, the closed windows a query merges; `max_windows` when unknown. + pub windows: Option, + /// `b`, the window boundaries one update crosses; unbounded when unknown. + pub boundaries: Option, + /// The inner data of each window's state. + pub inner: D, +} + +/// `A = max(pool_initial_size, max_windows + 2)`, the most states alive. +fn tumbling_states

(params: &TumblingParams

) -> usize { + params.pool_initial_size.max(params.max_windows + 2) +} + +/// The most slots a `Vec` built with capacity `initial` reaches holding at +/// most `most` items: `initial`, or `max(2·initial, 2(most − 1), 4)` once +/// it grows. +pub(crate) fn vec_slots(initial: usize, most: usize) -> usize { + if most <= initial { + initial + } else { + (2 * initial).max(2 * (most - 1)).max(4) + } +} + +impl SketchAnalytics for TumblingWindow +where + S: TumblingWindowSketch + InnerSketch, +{ + type Params = TumblingParams; + type Data = TumblingData; + + fn theoretical_memory_bytes(params: &Self::Params, data: &Self::Data) -> Option { + let states = tumbling_states(params); + let heap = + S::theoretical_memory_bytes(¶ms.inner, &data.inner)?.saturating_sub(size_of::()); + let closed_slots = vec_slots(params.max_windows, params.max_windows + 1); + let free_slots = vec_slots(params.pool_initial_size, states); + Some( + size_of::() + .saturating_add( + closed_slots.saturating_mul(crate::tumbling::closed_window_bytes::()), + ) + .saturating_add(free_slots.saturating_mul(size_of::())) + .saturating_add(states.saturating_mul(heap)), + ) + } + + fn theoretical_update_ops(params: &Self::Params, data: &Self::Data) -> f64 { + let boundaries = data.boundaries.map_or(f64::INFINITY, |b| b as f64); + let boundary = 4.0 + + 2.0 * params.max_windows as f64 + + S::theoretical_cells(¶ms.inner, &data.inner); + S::theoretical_update_ops(¶ms.inner, &data.inner) + boundaries * boundary + } + + fn theoretical_query_ops( + statistic: StatisticKind, + params: &Self::Params, + data: &Self::Data, + ) -> Option { + S::theoretical_query_ops(statistic, ¶ms.inner, &data.inner)?; + let windows = data + .windows + .unwrap_or(params.max_windows) + .min(params.max_windows) as f64; + let merge = S::theoretical_merge_ops(¶ms.inner, &data.inner)?; + Some(S::theoretical_cells(¶ms.inner, &data.inner) + windows * (1.0 + merge)) + } + + fn theoretical_merge_ops(_: &Self::Params, _: &Self::Data) -> Option { + None + } + + fn theoretical_error( + statistic: StatisticKind, + params: &Self::Params, + data: &Self::Data, + delta: Option, + ) -> Option { + S::SHARED_SEED_MERGE_BOUND.then_some(())?; + S::theoretical_error(statistic, ¶ms.inner, &data.inner, delta) + } + + fn params_for( + statistic: StatisticKind, + epsilon: f64, + delta: f64, + data: &Self::Data, + ) -> Option { + S::SHARED_SEED_MERGE_BOUND.then_some(())?; + S::params_for(statistic, epsilon, delta, &data.inner).map(TumblingParams::around) + } +} + +// --------------------------------------------------------------------------- +// HashSketchEnsemble +// --------------------------------------------------------------------------- + +/// One sketch of a HashSketchEnsemble and its parameters. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EnsembleCell { + /// A Count-Min on `Vector2D` storage of `counter_bytes`-byte counters. + CountMin { + shape: MatrixParams, + counter_bytes: usize, + }, + /// A Count Sketch on `Vector2D` storage of `counter_bytes`-byte counters. + CountSketch { + shape: MatrixParams, + counter_bytes: usize, + }, + HllErtl(HllParams), + HllClassic(HllParams), + HllHip(HllParams), +} + +impl EnsembleCell { + fn cell(&self) -> Cell<'_> { + match self { + Self::CountMin { shape, .. } => Cell::CountMin(shape), + Self::CountSketch { shape, .. } => Cell::CountSketch(shape), + Self::HllErtl(p) => Cell::HllErtl(p), + Self::HllClassic(p) => Cell::HllClassic(p), + Self::HllHip(p) => Cell::HllHip(p), + } + } + + /// Bytes this sketch holds beyond its `EnsembleSketch` slot: a boxed + /// matrix's struct and counters, or an inline HyperLogLog's heap. + fn bytes(&self, data: &CellData) -> Option { + let matrix = |inline: usize, shape: &MatrixParams, counter_bytes: usize| { + let counters = shape.rows.saturating_mul(shape.cols); + Some(inline.saturating_add(counters.saturating_mul(counter_bytes))) + }; + match self { + Self::CountMin { + shape, + counter_bytes, + } => matrix(size_of::(), shape, *counter_bytes), + Self::CountSketch { + shape, + counter_bytes, + } => matrix(size_of::(), shape, *counter_bytes), + _ => self.cell().heap(data), + } + } +} + +/// The sketches of a HashSketchEnsemble, and `index`, the one the query and +/// error functions read. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EnsembleParams { + pub sketches: Vec, + pub index: usize, +} + +impl Default for EnsembleParams { + fn default() -> Self { + Self { + sketches: vec![EnsembleCell::CountMin { + shape: MatrixParams::default(), + counter_bytes: size_of::(), + }], + index: 0, + } + } +} + +impl + 'static> SketchAnalytics + for HashSketchEnsemble +{ + type Params = EnsembleParams; + type Data = CellData; + + fn theoretical_memory_bytes(params: &EnsembleParams, data: &CellData) -> Option { + params.sketches.iter().try_fold( + size_of::() + params.sketches.len() * size_of::(), + |bytes, sketch| Some(bytes.saturating_add(sketch.bytes(data)?)), + ) + } + + fn theoretical_update_ops(params: &EnsembleParams, data: &CellData) -> f64 { + params + .sketches + .iter() + .map(|sketch| 1.0 + sketch.cell().update(data)) + .sum() + } + + fn theoretical_query_ops( + statistic: StatisticKind, + params: &EnsembleParams, + data: &CellData, + ) -> Option { + let sketch = params.sketches.get(params.index)?; + Some(1.0 + sketch.cell().query(statistic, data)?) + } + + fn theoretical_merge_ops(_: &EnsembleParams, _: &CellData) -> Option { + None + } + + fn theoretical_error( + statistic: StatisticKind, + params: &EnsembleParams, + data: &CellData, + delta: Option, + ) -> Option { + params + .sketches + .get(params.index)? + .cell() + .error(statistic, data, delta) + } + + fn params_for(_: StatisticKind, _: f64, _: f64, _: &CellData) -> Option { + None + } +} + +// --------------------------------------------------------------------------- +// Octo +// --------------------------------------------------------------------------- + +#[cfg(feature = "octo-runtime")] +pub use octo::*; + +#[cfg(feature = "octo-runtime")] +mod octo { + use super::super::count_matrix::{ + COUNT_MIN_CONTRACT, COUNT_SKETCH_CONTRACT, count_min_error, count_sketch_error, + }; + use super::*; + use crate::{OCTO_MAX_PROMOTION_THRESHOLD, OctoConfig, OctoPartition, OctoPlan}; + + /// The parent sketch of an Octo runtime and its parameters; Count-Min and + /// Count Sketch parents hash on `RegularPath`. + #[derive(Debug, Clone, Copy, PartialEq)] + pub enum OctoParent { + CountMin(MatrixParams), + CountSketch(MatrixParams), + CountMinWithHeap(MatrixParams), + CountSketchWithHeap(MatrixParams), + /// A HyperLogLog parent; `threshold_zero` when its workers promote + /// every register change. + Hll { + params: HllParams, + threshold_zero: bool, + }, + DdSketch(DdParams), + /// UnivMon, Coco and Elastic parents. + Other, + } + + /// The data inputs of an Octo error bound. + #[derive(Debug, Clone, Copy, Default, PartialEq)] + pub struct OctoData { + /// `N`, the total update weight. + pub total_weight: Option, + /// `‖f‖₂`. + pub l2: Option, + /// Whether the parent is read after a flush. + pub flushed: bool, + /// The parent's inner data. + pub inner: CellData, + } + + const OCTO_CM_CONTRACT: &str = "octo_cm_residual_additive_v1"; + const OCTO_CS_CONTRACT: &str = "octo_cs_residual_additive_v1"; + + /// `τ_max`: the largest threshold a worker holds, the initial one or the + /// adaptive ceiling after the controller clamps it into + /// `[max(min_threshold, 1), 127]`. + pub fn octo_max_threshold(config: &OctoConfig) -> u32 { + let initial = config.threshold.get(); + config.adaptive.as_ref().map_or(initial, |adaptive| { + let floor = adaptive + .min_threshold + .clamp(1, OCTO_MAX_PROMOTION_THRESHOLD); + let ceiling = adaptive + .max_threshold + .clamp(floor, OCTO_MAX_PROMOTION_THRESHOLD); + initial.max(ceiling) + }) + } + + /// `K'`: 1 under `HashByKey`, `K` under `RoundRobin`. + pub fn octo_workers_per_key(config: &OctoConfig) -> usize { + match config.partition { + OctoPartition::HashByKey => 1, + OctoPartition::RoundRobin => config.num_workers.max(1), + } + } + + /// The residual error `ε_in·scale + K·(τ_max − 1)` around an inner bound. + fn residual( + inner: ErrorBound, + scale: f64, + workers: usize, + config: &OctoConfig, + contract: &'static str, + ) -> ErrorBound { + let held = workers as f64 * (octo_max_threshold(config).saturating_sub(1)) as f64; + ErrorBound { + metric: ErrorMetric::ABSOLUTE, + epsilon: inner.epsilon * scale + held, + delta: inner.delta, + contract, + } + } + + /// The ItemFrequency or Distinct error of an Octo parent read after the + /// queues drain, before or after a flush, as `docs/sketch_analytics.md` + /// gives it; `None` where the section gives none. + pub fn octo_error( + statistic: StatisticKind, + parent: &OctoParent, + config: &OctoConfig, + data: &OctoData, + delta: Option, + ) -> Option { + let k = config.num_workers.max(1); + let item = statistic == StatisticKind::ItemFrequency; + match parent { + OctoParent::CountMin(p) | OctoParent::CountMinWithHeap(p) if item => { + let inner = count_min_error(p, false, delta, COUNT_MIN_CONTRACT)?; + if data.flushed && matches!(parent, OctoParent::CountMin(_)) { + return Some(inner); + } + let n = data.total_weight? as f64; + Some(residual( + inner, + n, + octo_workers_per_key(config), + config, + OCTO_CM_CONTRACT, + )) + } + OctoParent::CountSketch(p) | OctoParent::CountSketchWithHeap(p) if item => { + let q = collision_probability(p.cols, false); + let inner = count_sketch_error(p.rows, q, delta, COUNT_SKETCH_CONTRACT)?; + if data.flushed && matches!(parent, OctoParent::CountSketch(_)) { + return Some(inner); + } + Some(residual(inner, data.l2?, k, config, OCTO_CS_CONTRACT)) + } + OctoParent::Hll { + params, + threshold_zero, + } if statistic == StatisticKind::Distinct => (data.flushed || *threshold_zero) + .then(|| Cell::HllClassic(params).error(statistic, &data.inner, delta))?, + OctoParent::DdSketch(p) if data.flushed => { + Cell::DdSketch(p).error(statistic, &data.inner, delta) + } + _ => None, + } + } + + /// Octo's memory: + /// `mem_in + K·(size_of::() + worker counter bytes)` + /// `+ K·queue_capacity·(size_of::() + size_of::() + 16)`. + pub fn octo_memory_bytes( + config: &OctoConfig, + parent_bytes: usize, + worker_counter_bytes: usize, + ) -> usize { + let k = config.num_workers.max(1); + let slots = config.queue_capacity.max(1); + parent_bytes + .saturating_add(k.saturating_mul(size_of::() + worker_counter_bytes)) + .saturating_add( + k.saturating_mul(slots) + .saturating_mul(crate::octo::queue_slot_bytes::()), + ) + } + + /// `τ_min`: the smallest threshold a worker holds, the initial one or the + /// adaptive floor, within `[1, 127]`. + pub fn octo_min_threshold(config: &OctoConfig) -> u32 { + let initial = config.threshold.get(); + config + .adaptive + .as_ref() + .map_or(initial, |adaptive| { + initial.min( + adaptive + .min_threshold + .clamp(1, OCTO_MAX_PROMOTION_THRESHOLD), + ) + }) + .clamp(1, OCTO_MAX_PROMOTION_THRESHOLD) + } + + /// Worker ops of one update: `d` counter writes; the parent receives at + /// most `d/τ_min` writes per update on average. + pub fn octo_update_ops(rows: usize, config: &OctoConfig) -> (f64, f64) { + (rows as f64, rows as f64 / octo_min_threshold(config) as f64) + } +} diff --git a/src/analytics/heavy_hitters.rs b/src/analytics/heavy_hitters.rs new file mode 100644 index 00000000..4af90eb0 --- /dev/null +++ b/src/analytics/heavy_hitters.rs @@ -0,0 +1,316 @@ +//! Analytics of the heavy-hitter sketches: SpaceSaving, ElasticSketch and +//! CocoSketch. + +use super::count_matrix::{MatrixParams, count_min_error, count_min_params_for}; +use super::{ErrorBound, ErrorMetric, SketchAnalytics, StatisticKind, fixed_delta, sort_ops}; +use crate::sketches::coco::{CocoBucket, DEFAULT_DEPTH, DEFAULT_WIDTH}; +use crate::sketches::elastic::{DEFAULT_LIGHT_COLS, DEFAULT_LIGHT_ROWS, HeavyBucket}; +use crate::{ + CocoSketch, DefaultXxHasher, ElasticSketch, SPACE_SAVING_DEFAULT_CAPACITY, SketchHasher, + SpaceSaving, +}; + +/// `m`, the counters of a SpaceSaving summary. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SpaceSavingParams { + pub capacity: usize, +} + +impl Default for SpaceSavingParams { + fn default() -> Self { + Self { + capacity: SPACE_SAVING_DEFAULT_CAPACITY, + } + } +} + +/// SpaceSaving's data inputs. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct SpaceSavingData { + /// Bytes each key owns: a string's or byte slice's length, 0 for a + /// number. + pub key_bytes: Option, + /// `k`, the TopK size. Unknown counts as `m`. + pub top_k: Option, + /// `m_o`, the capacity of the state merged in. Unknown is unbounded. + pub other_capacity: Option, + /// Whether the state has been merged into. Unknown counts as merged. + pub merged: Option, +} + +const SPACE_SAVING_CONTRACT: &str = "space_saving_min_counter_v1"; + +impl SketchAnalytics for SpaceSaving { + type Params = SpaceSavingParams; + type Data = SpaceSavingData; + + fn theoretical_memory_bytes( + params: &SpaceSavingParams, + data: &SpaceSavingData, + ) -> Option { + Some(SpaceSaving::::theoretical_memory_bytes( + params.capacity, + data.key_bytes?, + )) + } + + fn theoretical_update_ops(_: &SpaceSavingParams, _: &SpaceSavingData) -> f64 { + 47.0 + } + + fn theoretical_query_ops( + statistic: StatisticKind, + params: &SpaceSavingParams, + data: &SpaceSavingData, + ) -> Option { + let m = params.capacity.max(1); + match statistic { + StatisticKind::ItemFrequency => Some(4.0), + StatisticKind::TopK => Some(1.0 + 7.0 * data.top_k.unwrap_or(m).min(m) as f64), + _ => None, + } + } + + fn theoretical_merge_ops(params: &SpaceSavingParams, data: &SpaceSavingData) -> Option { + let m = params.capacity.max(1) as f64; + let other = data + .other_capacity + .map_or(f64::INFINITY, |c| c.max(1) as f64); + Some(34.0 * m + 10.0 * other + sort_ops(m + other) + 13.0) + } + + fn theoretical_error( + statistic: StatisticKind, + params: &SpaceSavingParams, + data: &SpaceSavingData, + delta: Option, + ) -> Option { + (matches!( + statistic, + StatisticKind::ItemFrequency | StatisticKind::TopK + ) && data.merged == Some(false)) + .then_some(())?; + Some(ErrorBound { + metric: ErrorMetric::L1, + epsilon: 1.0 / params.capacity.max(1) as f64, + delta: Some(fixed_delta(0.0, delta)?), + contract: SPACE_SAVING_CONTRACT, + }) + } + + fn params_for( + statistic: StatisticKind, + epsilon: f64, + _: f64, + data: &SpaceSavingData, + ) -> Option { + (matches!( + statistic, + StatisticKind::ItemFrequency | StatisticKind::TopK + ) && data.merged == Some(false)) + .then_some(())?; + let capacity = (1.0 / epsilon).ceil(); + (epsilon > 0.0 && capacity <= u32::MAX as f64).then_some(SpaceSavingParams { + capacity: capacity.max(1.0) as usize, + }) + } +} + +/// `H` heavy buckets over a light Count-Min of `d` rows and `w` columns. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ElasticParams { + pub bucket_count: usize, + pub light_rows: usize, + pub light_cols: usize, +} + +impl Default for ElasticParams { + fn default() -> Self { + Self { + bucket_count: 8, + light_rows: DEFAULT_LIGHT_ROWS, + light_cols: DEFAULT_LIGHT_COLS, + } + } +} + +/// The data inputs of ElasticSketch, CocoSketch, UnivMon and the matrices +/// with a heap or a Space-Saving summary. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct FlowData { + /// `D`, the distinct keys. + pub distinct: Option, + /// Bytes each key owns: a string's or byte slice's length, 0 for a + /// number, and for ElasticSketch the capacity of the `String` passed in. + pub key_bytes: Option, + /// Whether the state has been merged into, read by ElasticSketch. + /// Unknown counts as merged. + pub merged: Option, +} + +/// Light rows that bound an Elastic estimate: light row 5 hashes with the +/// heavy part's seed, so rows past the fifth add nothing to `δ`. +const ELASTIC_INDEPENDENT_ROWS: usize = 5; + +const ELASTIC_CONTRACT: &str = "elastic_one_sided_light_cms_v1"; + +fn elastic_light(params: &ElasticParams) -> MatrixParams { + MatrixParams { + rows: params.light_rows.min(ELASTIC_INDEPENDENT_ROWS), + cols: params.light_cols, + } +} + +fn elastic_sized(light: MatrixParams) -> ElasticParams { + ElasticParams { + light_rows: light.rows, + light_cols: light.cols, + ..ElasticParams::default() + } +} + +impl SketchAnalytics for ElasticSketch { + type Params = ElasticParams; + type Data = FlowData; + + fn theoretical_memory_bytes(params: &ElasticParams, data: &FlowData) -> Option { + let bucket = size_of::().saturating_add(data.key_bytes?); + Some( + size_of::() + .saturating_add(params.bucket_count.saturating_mul(bucket)) + .saturating_add( + (params.light_rows.saturating_mul(params.light_cols)) + .saturating_mul(size_of::()), + ), + ) + } + + fn theoretical_update_ops(params: &ElasticParams, _: &FlowData) -> f64 { + 2.0 + params.light_rows as f64 + } + + fn theoretical_query_ops( + statistic: StatisticKind, + params: &ElasticParams, + _: &FlowData, + ) -> Option { + let d = params.light_rows as f64; + let h = params.bucket_count as f64; + match statistic { + StatisticKind::ItemFrequency => Some(1.0 + d), + StatisticKind::TopK => Some(h * (5.0 + d) + 2.0 * sort_ops(h)), + _ => None, + } + } + + fn theoretical_merge_ops(params: &ElasticParams, _: &FlowData) -> Option { + let d = params.light_rows as f64; + let h = params.bucket_count as f64; + Some(h * (13.0 + 3.0 * d) + 2.0 * d * params.light_cols as f64) + } + + fn theoretical_error( + statistic: StatisticKind, + params: &ElasticParams, + data: &FlowData, + delta: Option, + ) -> Option { + (data.merged == Some(false)).then_some(())?; + let light = elastic_light(params); + match statistic { + StatisticKind::ItemFrequency => count_min_error(&light, false, delta, ELASTIC_CONTRACT), + StatisticKind::TopK => { + let keys = data.distinct? as f64; + let per_key = count_min_error(&light, false, None, ELASTIC_CONTRACT)?; + let fixed = keys * per_key.delta?; + (fixed < 1.0).then_some(())?; + Some(ErrorBound { + delta: Some(fixed_delta(fixed, delta)?), + ..per_key + }) + } + _ => None, + } + } + + fn params_for( + statistic: StatisticKind, + epsilon: f64, + delta: f64, + data: &FlowData, + ) -> Option { + (data.merged == Some(false)).then_some(())?; + let per_key = match statistic { + StatisticKind::ItemFrequency => delta, + StatisticKind::TopK => delta / data.distinct?.max(1) as f64, + _ => return None, + }; + count_min_params_for(epsilon, per_key, false, ELASTIC_INDEPENDENT_ROWS).map(elastic_sized) + } +} + +/// `d` rows and `w` columns of a CocoSketch. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CocoParams { + pub rows: usize, + pub cols: usize, +} + +impl Default for CocoParams { + fn default() -> Self { + Self { + rows: DEFAULT_DEPTH, + cols: DEFAULT_WIDTH, + } + } +} + +impl SketchAnalytics for CocoSketch { + type Params = CocoParams; + type Data = FlowData; + + fn theoretical_memory_bytes(params: &CocoParams, data: &FlowData) -> Option { + let cells = params.rows.saturating_mul(params.cols); + let held = data.distinct.map_or(cells, |d| { + cells.min(usize::try_from(d).unwrap_or(usize::MAX)) + }); + Some( + size_of::() + .saturating_add(cells.saturating_mul(size_of::())) + .saturating_add(held.saturating_mul(data.key_bytes?)), + ) + } + + fn theoretical_update_ops(params: &CocoParams, _: &FlowData) -> f64 { + params.rows as f64 + 1.0 + } + + fn theoretical_query_ops( + statistic: StatisticKind, + params: &CocoParams, + _: &FlowData, + ) -> Option { + match statistic { + StatisticKind::ItemFrequency => Some(params.rows as f64), + StatisticKind::PartialKeySum => Some((params.rows * params.cols) as f64), + _ => None, + } + } + + fn theoretical_merge_ops(params: &CocoParams, _: &FlowData) -> Option { + Some((params.rows * params.cols * (params.rows + 2)) as f64) + } + + fn theoretical_error( + _: StatisticKind, + _: &CocoParams, + _: &FlowData, + _: Option, + ) -> Option { + None + } + + fn params_for(_: StatisticKind, _: f64, _: f64, _: &FlowData) -> Option { + None + } +} diff --git a/src/analytics/hydra.rs b/src/analytics/hydra.rs new file mode 100644 index 00000000..20dd2fe9 --- /dev/null +++ b/src/analytics/hydra.rs @@ -0,0 +1,356 @@ +//! Analytics of Hydra, a grid of cells over every subpopulation of a key +//! schema. + +use super::cardinality::HllParams; +use super::count_matrix::{MatrixParams, collision_probability}; +use super::frameworks::{Cell, CellData, split_delta}; +use super::quantiles::KllParams; +use super::univmon::UnivMonParams; +use super::{ + ErrorBound, ErrorMetric, SketchAnalytics, StatisticKind, is_positive, p_med, sort_ops, +}; +use crate::Hydra; +use crate::input::HydraCounter; + +/// A Hydra cell's kind and parameters. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HydraCell { + CountMin(MatrixParams), + CountSketch(MatrixParams), + /// An `Ertl` HyperLogLog. + Hll(HllParams), + Kll(KllParams), + UnivMon(UnivMonParams), +} + +impl HydraCell { + fn cell(&self) -> Cell<'_> { + match self { + Self::CountMin(p) => Cell::CountMin(p), + Self::CountSketch(p) => Cell::CountSketch(p), + Self::Hll(p) => Cell::HllErtl(p), + Self::Kll(p) => Cell::Kll(p), + Self::UnivMon(p) => Cell::UnivMon(p), + } + } +} + +/// `d` rows, `w` columns and schema arity `a` of a Hydra, and its cells. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HydraParams { + pub rows: usize, + pub cols: usize, + pub arity: usize, + pub cell: HydraCell, +} + +impl Default for HydraParams { + fn default() -> Self { + Self { + rows: 3, + cols: 1024, + arity: 1, + cell: HydraCell::CountMin(MatrixParams::default()), + } + } +} + +/// The data inputs of Hydra. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct HydraData { + /// `n`, the updates. + pub n: Option, + /// `N`, their total weight. + pub total_weight: Option, + /// The schema labels' total length in bytes, escapes included. + pub label_bytes: Option, + /// A lower bound on `N_Q`, the queried subpopulation's weight. + pub group_weight: Option, + /// A lower bound on `D_Q`, its distinct values. + pub group_distinct: Option, + /// A lower bound on `‖f_Q‖₂`. + pub group_l2: Option, + /// The schema arity [`SketchAnalytics::params_for`] sizes for; 1 when + /// unknown. + pub arity: Option, + /// Whether another Hydra has been merged into this one, read by Kll + /// cells. Unknown counts as merged. + pub merged: Option, + /// Each cell's inner data. + pub cell: CellData, +} + +const CM_CONTRACT: &str = "hydra_cm_cell_markov_median_binomial_v1"; +const CM_TOTAL_CONTRACT: &str = "hydra_cm_group_total_markov_min_v1"; +const CS_CONTRACT: &str = "hydra_cs_cell_markov_median_binomial_v1"; +const KLL_CONTRACT: &str = "hydra_kll_cell_mixture_rank_median_binomial_v1"; +const UM_TOTAL_CONTRACT: &str = "hydra_univmon_total_markov_median_binomial_v1"; +const UM_L2_CONTRACT: &str = "hydra_univmon_l2_collision_median_binomial_v1"; +const UM_DISTINCT_CONTRACT: &str = "hydra_univmon_distinct_collision_median_binomial_v1"; + +/// `2^a − 1`, the subkeys one update writes. +fn subkeys(arity: usize) -> f64 { + ((1u64 << arity.min(63)) - 1) as f64 +} + +/// `4(2^a − 1)/w`: the colliding weight, per unit of `N`, one row's cell +/// holds with probability at least `1 − q·w/4`. +fn collision_share(params: &HydraParams) -> f64 { + 4.0 * subkeys(params.arity) / params.cols as f64 +} + +/// `q·w/4`, one row's collision failure probability. +fn row_failure(params: &HydraParams) -> f64 { + collision_probability(params.cols, true) * params.cols as f64 / 4.0 +} + +/// A median over rows of one cell each: `P_med(d, q·w/4) + d·δ_in`, with +/// `ε` built from the inner bound by `epsilon`. +fn median_bound( + statistic: StatisticKind, + params: &HydraParams, + data: &HydraData, + delta: Option, + metric: ErrorMetric, + contract: &'static str, + epsilon: impl Fn(f64) -> Option, +) -> Option { + let cell = params.cell.cell(); + let inner = |delta| cell.error(statistic, &data.cell, delta); + let own = p_med(params.rows, row_failure(params)); + let (bound, total) = split_delta(own, params.rows as f64, delta, inner)?; + Some(ErrorBound { + metric, + epsilon: epsilon(bound.epsilon)?, + delta: Some(total), + contract, + }) +} + +/// `C_total`: the most ops one cell's group total costs, a Count-Min row-0 +/// sum or a KLL's per-level count. +fn total_mass_ops(cell: &HydraCell, data: &HydraData) -> Option { + match cell { + HydraCell::CountMin(p) => Some(p.cols as f64), + HydraCell::Kll(p) => { + let levels = super::quantiles::kll_levels(p, data.cell.stream.n); + Some(2.0 * levels as f64) + } + _ => None, + } +} + +impl SketchAnalytics for Hydra { + type Params = HydraParams; + type Data = HydraData; + + fn theoretical_memory_bytes(params: &HydraParams, data: &HydraData) -> Option { + let cells = params.rows * params.cols; + let heap = params.cell.cell().heap(&data.cell)?; + let labels = (2 * params.arity * size_of::()) + .saturating_add(data.label_bytes?.saturating_mul(2)); + Some( + size_of::() + .saturating_add(cells.saturating_mul(size_of::())) + .saturating_add((cells + 1).saturating_mul(heap)) + .saturating_add(labels), + ) + } + + fn theoretical_update_ops(params: &HydraParams, data: &HydraData) -> f64 { + let inner = params.cell.cell().update(&data.cell); + subkeys(params.arity) * params.rows as f64 * (1.0 + inner) + } + + fn theoretical_query_ops( + statistic: StatisticKind, + params: &HydraParams, + data: &HydraData, + ) -> Option { + let d = params.rows as f64; + let cell = params.cell.cell(); + let readout = match (¶ms.cell, statistic) { + (HydraCell::CountMin(_) | HydraCell::Kll(_), StatisticKind::Total) => { + let mass = total_mass_ops(¶ms.cell, data)?; + return Some(d + (d + 1.0) * mass); + } + (HydraCell::Kll(_), StatisticKind::Rank) => { + cell.query(StatisticKind::Quantile, &data.cell)? + } + (HydraCell::UnivMon(_), StatisticKind::FrequencyEntropy) + | (HydraCell::UnivMon(_), StatisticKind::Total) + | (HydraCell::UnivMon(_), StatisticKind::FrequencyL2) + | (HydraCell::UnivMon(_), StatisticKind::Distinct) + | (HydraCell::CountMin(_) | HydraCell::CountSketch(_), StatisticKind::ItemFrequency) + | (HydraCell::Hll(_), StatisticKind::Distinct) + | (HydraCell::Kll(_), StatisticKind::Quantile) => cell.query(statistic, &data.cell)?, + _ => return None, + }; + Some((d + 1.0) * readout + 2.0 * d + sort_ops(d)) + } + + fn theoretical_merge_ops(params: &HydraParams, data: &HydraData) -> Option { + let merge = params.cell.cell().merge(&data.cell)?; + Some((params.rows * params.cols) as f64 * (2.0 + merge)) + } + + fn theoretical_error( + statistic: StatisticKind, + params: &HydraParams, + data: &HydraData, + delta: Option, + ) -> Option { + let share = collision_share(params); + match (¶ms.cell, statistic) { + (HydraCell::CountMin(_), StatisticKind::ItemFrequency) => median_bound( + statistic, + params, + data, + delta, + ErrorMetric::L1, + CM_CONTRACT, + |inner| Some(share * (1.0 + inner) + inner), + ), + (HydraCell::CountSketch(_), StatisticKind::ItemFrequency) => median_bound( + statistic, + params, + data, + delta, + ErrorMetric::L1, + CS_CONTRACT, + |inner| Some(share * (1.0 + inner) + inner), + ), + (HydraCell::CountMin(_), StatisticKind::Total) => Some(ErrorBound { + metric: ErrorMetric::L1, + epsilon: share, + delta: Some(super::fixed_delta( + row_failure(params).powi(params.rows as i32), + delta, + )?), + contract: CM_TOTAL_CONTRACT, + }), + (HydraCell::Kll(_), StatisticKind::Quantile | StatisticKind::Rank) => { + (data.merged == Some(false)).then_some(())?; + let mixed = share * data.total_weight? as f64 / data.group_weight?.max(1) as f64; + let metric = if statistic == StatisticKind::Quantile { + ErrorMetric::Rank + } else { + ErrorMetric::ABSOLUTE + }; + median_bound( + statistic, + params, + data, + delta, + metric, + KLL_CONTRACT, + |inner| Some(mixed + inner), + ) + } + (HydraCell::UnivMon(_), StatisticKind::Total) => Some(ErrorBound { + metric: ErrorMetric::L1, + epsilon: share, + delta: Some(super::fixed_delta( + p_med(params.rows, row_failure(params)), + delta, + )?), + contract: UM_TOTAL_CONTRACT, + }), + (HydraCell::UnivMon(_), StatisticKind::FrequencyL2) => { + let collided = share * data.total_weight? as f64 / data.group_l2?; + median_bound( + statistic, + params, + data, + delta, + ErrorMetric::Relative, + UM_L2_CONTRACT, + |inner| Some(inner + (1.0 + inner) * collided), + ) + } + (HydraCell::UnivMon(_), StatisticKind::Distinct) => { + let collided = share * data.n? as f64 / data.group_distinct?.max(1) as f64; + median_bound( + statistic, + params, + data, + delta, + ErrorMetric::Relative, + UM_DISTINCT_CONTRACT, + |inner| Some(inner + (1.0 + inner) * collided), + ) + } + _ => None, + } + } + + fn params_for( + statistic: StatisticKind, + epsilon: f64, + delta: f64, + data: &HydraData, + ) -> Option { + if !is_positive(epsilon) || !is_positive(delta) { + return None; + } + let half = epsilon / 2.0; + let inner_epsilon = half / (1.0 + half); + let rows = (1..=99) + .step_by(2) + .find(|&d| p_med(d, 0.25) <= delta / 2.0)?; + let inner_delta = delta / (2.0 * rows as f64); + let arity = data.arity.unwrap_or(1); + let cell = match statistic { + StatisticKind::ItemFrequency => { + HydraCell::CountMin(::params_for( + statistic, + inner_epsilon, + inner_delta, + &(), + )?) + } + StatisticKind::Quantile | StatisticKind::Rank => { + (data.merged == Some(false)).then_some(())?; + HydraCell::Kll(::params_for( + statistic, + inner_epsilon, + inner_delta, + &data.cell.stream, + )?) + } + StatisticKind::FrequencyL2 => { + HydraCell::UnivMon(::params_for( + statistic, + inner_epsilon, + inner_delta, + &data.cell.flow, + )?) + } + _ => return None, + }; + let realized = cell + .cell() + .error(statistic, &data.cell, Some(inner_delta))? + .epsilon; + // The collision term at `w = 1`, which halves with each doubling. + let at_one = 4.0 + * subkeys(arity) + * match statistic { + StatisticKind::ItemFrequency => 1.0 + realized, + StatisticKind::Quantile | StatisticKind::Rank => { + data.total_weight? as f64 / data.group_weight?.max(1) as f64 + } + StatisticKind::FrequencyL2 => { + (1.0 + realized) * data.total_weight? as f64 / data.group_l2? + } + _ => return None, + }; + let cols = (at_one / half).ceil().max(1.0); + (cols <= (1u64 << 32) as f64).then(|| HydraParams { + rows, + cols: (cols as usize).next_power_of_two(), + arity, + cell, + }) + } +} diff --git a/src/analytics/mod.rs b/src/analytics/mod.rs new file mode 100644 index 00000000..3d291186 --- /dev/null +++ b/src/analytics/mod.rs @@ -0,0 +1,345 @@ +//! Theoretical error, memory and op counts per sketch, as +//! `docs/sketch_analytics.md` defines them. +//! +//! Each sketch type implements [`SketchAnalytics`]. A caller with parameters +//! calls its functions directly, and a caller with a target `(ε, δ)` first +//! calls [`SketchAnalytics::params_for`]. + +pub mod cardinality; +pub mod count_matrix; +pub mod eh; +pub mod frameworks; +pub mod heavy_hitters; +pub mod hydra; +pub mod ops; +pub mod quantiles; +pub mod top_k; +pub mod univmon; + +pub use crate::capability::StatisticKind; +pub use cardinality::{BloomParams, DistinctData, HllData, HllParams, KmvParams, SetData}; +pub use count_matrix::MatrixParams; +#[cfg(feature = "experimental")] +pub use count_matrix::{CountMinHllParams, MicroCmData, MicroCmParams}; +pub use eh::{EhCell, EhData, EhParams}; +#[cfg(feature = "experimental")] +pub use eh::{EhUnivMonData, EhUnivMonParams}; +pub use frameworks::{ + CellData, EnsembleCell, EnsembleParams, InnerSketch, NitroData, NitroInner, NitroParams, + TumblingData, TumblingParams, +}; +#[cfg(feature = "octo-runtime")] +pub use frameworks::{OctoData, OctoParent}; +pub use heavy_hitters::{CocoParams, ElasticParams, FlowData, SpaceSavingData, SpaceSavingParams}; +pub use hydra::{HydraCell, HydraData, HydraParams}; +#[cfg(feature = "experimental")] +pub use quantiles::UniformParams; +pub use quantiles::{DdData, DdParams, KllParams, StreamData}; +pub use top_k::{FoldData, FoldParams, MatrixHeapParams, MatrixSpaceSavingParams}; +pub use univmon::{TwoTierParams, UnivMonParams, UnivMonQData}; + +use crate::{FastPath, RegularPath}; + +/// Whether a matrix sketch's hash path is `FastPath`. +pub trait HashPath { + const FAST: bool; +} + +impl HashPath for RegularPath { + const FAST: bool = false; +} + +impl HashPath for FastPath { + const FAST: bool = true; +} + +/// The `δ` a contract whose `δ` the caller chooses uses when +/// [`SketchAnalytics::theoretical_error`] gets `delta = None`. +pub const DEFAULT_DELTA: f64 = 0.01; + +/// The stream norm an [`ErrorMetric::Absolute`] error is stated in multiples of. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum NormScale { + /// `N`, the total update weight. + StreamL1, + /// `‖f‖₂`, the square root of the sum of squared key weights. + StreamL2, +} + +/// The error quantity an [`ErrorBound`] bounds. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ErrorMetric { + /// Off by at most `ε`, or `ε` times the stream norm `scale`. + Absolute { scale: Option }, + /// Off by at most `ε` times the true value. + Relative, + /// The returned value's rank is within `ε·n` of the requested rank. + Rank, +} + +impl ErrorMetric { + /// `Absolute { scale: Some(StreamL1) }`. + pub const L1: Self = Self::Absolute { + scale: Some(NormScale::StreamL1), + }; + /// `Absolute { scale: Some(StreamL2) }`. + pub const L2: Self = Self::Absolute { + scale: Some(NormScale::StreamL2), + }; + /// `Absolute { scale: None }`. + pub const ABSOLUTE: Self = Self::Absolute { scale: None }; +} + +/// With probability at least `1 − δ`, one estimate from one state is off by +/// at most `ε` in `metric`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ErrorBound { + pub metric: ErrorMetric, + pub epsilon: f64, + /// `None` when the analysis gives only a standard error. + pub delta: Option, + /// The analysis, named as ASAPPlanner names it where it has the same one. + pub contract: &'static str, +} + +impl ErrorBound { + /// An exact readout. + pub const EXACT: Self = Self { + metric: ErrorMetric::ABSOLUTE, + epsilon: 0.0, + delta: Some(0.0), + contract: "exact_v1", + }; +} + +/// Theoretical error, memory and ops of one sketch type. +/// +/// `Data` holds the data inputs a sketch's formulas read, each an `Option`. +/// When one is unknown, memory and ops use its upper bound, memory returns +/// `None` when it has none, and [`Self::theoretical_error`] and +/// [`Self::params_for`] return `None` if they need it. +pub trait SketchAnalytics { + type Params; + type Data: Default; + + /// Bytes a state with `params` holds after the updates `data` describes; + /// `None` when it needs an unknown data input that has no upper bound. + fn theoretical_memory_bytes(params: &Self::Params, data: &Self::Data) -> Option; + + /// Most ops one plain update costs, or its average where the sketch's + /// section marks it average. + fn theoretical_update_ops(params: &Self::Params, data: &Self::Data) -> f64; + + /// Most ops one query of `statistic` costs; `None` when the sketch does + /// not answer it. + fn theoretical_query_ops( + statistic: StatisticKind, + params: &Self::Params, + data: &Self::Data, + ) -> Option; + + /// Most ops merging one state into another costs; `None` when the sketch + /// does not merge. + fn theoretical_merge_ops(params: &Self::Params, data: &Self::Data) -> Option; + + /// The error of one `statistic` estimate. + /// + /// For a contract with a fixed `δ`, `delta = None` or a `delta` at or + /// above it returns that `δ`, and a `delta` below it returns `None`. For a + /// contract whose `δ` the caller chooses, it returns the smallest `ε` at + /// `delta`, or at [`DEFAULT_DELTA`] when `delta` is `None`. `None` when + /// the statistic has no bound. + fn theoretical_error( + statistic: StatisticKind, + params: &Self::Params, + data: &Self::Data, + delta: Option, + ) -> Option; + + /// Parameters that meet `(epsilon, delta)` for `statistic`; `None` when + /// no parameters the constructor accepts can. + fn params_for( + statistic: StatisticKind, + epsilon: f64, + delta: f64, + data: &Self::Data, + ) -> Option; +} + +/// `x > 0`, false for NaN. +pub(crate) fn is_positive(x: f64) -> bool { + x > 0.0 +} + +/// `⌈log2 x⌉`, and 0 for `x ≤ 1`. +pub fn lg(x: f64) -> f64 { + if x <= 1.0 { 0.0 } else { x.log2().ceil() } +} + +/// `⌊log2 k⌋`, and 0 for `k ≤ 1`. +pub fn floor_log2(k: usize) -> f64 { + if k <= 1 { 0.0 } else { k.ilog2() as f64 } +} + +/// `p·lg p`, the cost of sorting `p` items or taking their median. +pub fn sort_ops(p: f64) -> f64 { + p * lg(p) +} + +/// Ops a Count-Min estimate over `d` rows costs: the minimum of `d` counters. +pub fn est_count_min(d: usize) -> f64 { + d as f64 +} + +/// Ops a Count Sketch estimate over `d` rows costs: copy `d` counters into a +/// buffer and take their median. +pub fn est_count_sketch(d: usize) -> f64 { + 2.0 * d as f64 + sort_ops(d as f64) +} + +/// `h(k)`, the most ops one `TopKHeap` update costs at capacity `k`. +pub fn heap_update_ops(k: usize) -> f64 { + 8.0 + 14.0 * floor_log2(k) +} + +/// `P_med(d, β) = P(Binomial(d, β) ≥ ⌈d/2⌉)`: the chance a median over `d` +/// rows, each wrong independently with probability `β`, is wrong. +pub fn p_med(d: usize, beta: f64) -> f64 { + if d == 0 { + return 1.0; + } + let beta = beta.clamp(0.0, 1.0); + let need = d.div_ceil(2); + let mut total = 0.0; + for j in need..=d { + total += (ln_choose(d, j) + j as f64 * beta.ln() + (d - j) as f64 * (-beta).ln_1p()).exp(); + } + total.min(1.0) +} + +/// The largest per-row failure probability `β` with `P_med(d, β) ≤ delta`. +pub fn max_row_failure(d: usize, delta: f64) -> f64 { + if delta >= 1.0 { + return 1.0; + } + if delta <= 0.0 { + return 0.0; + } + let (mut lo, mut hi) = (0.0f64, 1.0f64); + for _ in 0..200 { + let mid = 0.5 * (lo + hi); + if p_med(d, mid) <= delta { + lo = mid; + } else { + hi = mid; + } + } + lo +} + +fn ln_choose(n: usize, k: usize) -> f64 { + let k = k.min(n - k); + (0..k).map(|i| ((n - i) as f64 / (i + 1) as f64).ln()).sum() +} + +/// The standard normal quantile `z(p)` for `p` in (0, 1). +pub fn normal_quantile(p: f64) -> f64 { + let (mut lo, mut hi) = (-40.0f64, 40.0f64); + for _ in 0..200 { + let mid = 0.5 * (lo + hi); + if normal_cdf(mid) < p { + lo = mid; + } else { + hi = mid; + } + } + 0.5 * (lo + hi) +} + +/// The `x` with `P(Z > x) = tail` for a standard normal `Z` and `tail` in +/// (0, 1), solved on the upper tail so tails below `f64::EPSILON` resolve. +pub fn normal_upper_quantile(tail: f64) -> f64 { + let (mut lo, mut hi) = (-40.0f64, 40.0f64); + for _ in 0..200 { + let mid = 0.5 * (lo + hi); + if 0.5 * erfc(mid / std::f64::consts::SQRT_2) > tail { + lo = mid; + } else { + hi = mid; + } + } + 0.5 * (lo + hi) +} + +fn normal_cdf(x: f64) -> f64 { + 0.5 * erfc(-x / std::f64::consts::SQRT_2) +} + +// Numerical Recipes' erfc, relative error below 1.2e-7. +fn erfc(x: f64) -> f64 { + let z = x.abs(); + let t = 1.0 / (1.0 + 0.5 * z); + let poly = -z * z - 1.265_512_23 + + t * (1.000_023_68 + + t * (0.374_091_96 + + t * (0.096_784_18 + + t * (-0.186_288_06 + + t * (0.278_868_07 + + t * (-1.135_203_98 + + t * (1.488_515_87 + t * (-0.822_152_23 + t * 0.170_872_77)))))))); + let r = t * poly.exp(); + if x >= 0.0 { r } else { 2.0 - r } +} + +/// Resolves a requested `delta` against a contract's fixed `δ`: the fixed +/// `δ` when `delta` is `None` or at least it, `None` when below it. +pub fn fixed_delta(fixed: f64, delta: Option) -> Option { + match delta { + Some(requested) if requested < fixed => None, + _ => Some(fixed), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn p_med_matches_hand_computed_binomial_tails() { + assert_eq!(p_med(1, 0.25), 0.25); + // P(Bin(3, 0.1) ≥ 2) = 3·0.01·0.9 + 0.001. + assert!((p_med(3, 0.1) - 0.028).abs() < 1e-12); + // Even d: P(Bin(2, 0.5) ≥ 1) = 0.75. + assert!((p_med(2, 0.5) - 0.75).abs() < 1e-12); + } + + #[test] + fn max_row_failure_inverts_p_med() { + for d in [1, 3, 5, 9, 19] { + for delta in [0.1, 0.01, 1e-4] { + let beta = max_row_failure(d, delta); + assert!(p_med(d, beta) <= delta); + assert!(p_med(d, beta * 1.001) > delta); + } + } + } + + #[test] + fn normal_quantile_hits_known_values() { + assert!((normal_quantile(0.995) - 2.575_829).abs() < 1e-5); + assert!((normal_quantile(0.975) - 1.959_964).abs() < 1e-5); + assert!(normal_quantile(0.5).abs() < 1e-6); + assert!((normal_upper_quantile(0.005) - 2.575_829).abs() < 1e-5); + assert!((normal_upper_quantile(5e-21) - 9.336_045).abs() < 1e-5); + } + + #[test] + fn lg_and_floor_log2_round_the_documented_ways() { + assert_eq!(lg(1.0), 0.0); + assert_eq!(lg(5.0), 3.0); + assert_eq!(lg(8.0), 3.0); + assert_eq!(floor_log2(1), 0.0); + assert_eq!(floor_log2(5), 2.0); + assert_eq!(heap_update_ops(8), 50.0); + } +} diff --git a/src/analytics/ops.rs b/src/analytics/ops.rs new file mode 100644 index 00000000..2d42c0e1 --- /dev/null +++ b/src/analytics/ops.rs @@ -0,0 +1,56 @@ +//! Op counting behind the `op-count` feature. One op is one read or write of +//! a cell, counted as `docs/sketch_analytics.md` counts them. Without the +//! feature every counting call compiles to nothing. + +#[cfg(feature = "op-count")] +std::thread_local! { + static OPS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Adds `n` ops to this thread's count. +#[inline(always)] +pub fn add(n: usize) { + #[cfg(feature = "op-count")] + OPS.with(|ops| ops.set(ops.get() + n as u64)); + #[cfg(not(feature = "op-count"))] + let _ = n; +} + +/// Adds the cost of sorting `p` items or taking their median, `p·⌈log2 p⌉`. +#[inline(always)] +pub fn add_sort(p: usize) { + if p > 1 { + add(p * (usize::BITS - (p - 1).leading_zeros()) as usize); + } +} + +/// Runs `f` and returns its result with the ops it performed on this thread. +#[cfg(feature = "op-count")] +pub fn count(f: impl FnOnce() -> R) -> (R, u64) { + let before = OPS.with(std::cell::Cell::get); + let result = f(); + (result, OPS.with(std::cell::Cell::get) - before) +} + +#[cfg(all(test, feature = "op-count"))] +mod tests { + use super::*; + + #[test] + fn count_sees_only_the_ops_inside_the_closure() { + add(5); + let ((), ops) = count(|| { + add(3); + add_sort(8); + }); + assert_eq!(ops, 3 + 8 * 3); + } + + #[test] + fn add_sort_uses_the_ceiling_of_log2() { + let ((), ops) = count(|| add_sort(5)); + assert_eq!(ops, 15); + let ((), ops) = count(|| add_sort(1)); + assert_eq!(ops, 0); + } +} diff --git a/src/analytics/quantiles.rs b/src/analytics/quantiles.rs new file mode 100644 index 00000000..1486f99e --- /dev/null +++ b/src/analytics/quantiles.rs @@ -0,0 +1,535 @@ +//! Analytics of the quantile sketches Kll, KllDynamic and DdSketch, and the +//! experimental UniformSampling. + +use super::{ + DEFAULT_DELTA, ErrorBound, ErrorMetric, SketchAnalytics, StatisticKind, fixed_delta, + is_positive, lg, normal_upper_quantile, +}; +use crate::common::numerical::NumericalValue; +use crate::sketches::ddsketch::ddsketch_indexable_bounds; +use crate::sketches::kll::{ + CAPACITY_CACHE_LEN, DEFAULT_K, DEFAULT_M, MAX_K, MAX_LEVELS, capacity_table, levels_after, + normalized_k_m, +}; +use crate::{DdSketch, Kll, KllDynamic}; + +/// `k` and `m` of a KLL sketch, as its constructor takes them. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct KllParams { + pub k: usize, + pub m: usize, +} + +impl Default for KllParams { + fn default() -> Self { + Self { + k: DEFAULT_K as usize, + m: DEFAULT_M, + } + } +} + +/// `n`, the updates a state has received. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct StreamData { + pub n: Option, +} + +impl StreamData { + fn n(&self) -> Option { + self.n.map(|n| usize::try_from(n).unwrap_or(usize::MAX)) + } +} + +const KLL_QUANTILE_CONTRACT: &str = "kll_quantile_empirical_99_v1"; +const KLL_QUANTILE_SCALED_CONTRACT: &str = "kll_quantile_empirical_99_delta_scaled_v1"; +const KLL_RANK_CONTRACT: &str = "kll_rank_measured_99_v1"; +const KLL_RANK_SCALED_CONTRACT: &str = "kll_rank_measured_99_delta_scaled_v1"; + +/// The smallest `m` the KLL fits were measured at. +const KLL_FIT_MIN_M: usize = 8; + +/// `ε = c·s(δ)/k^p` of a KLL fit, for `k ≥ min_k`. +#[derive(Clone, Copy)] +struct KllFit { + constant: f64, + power: f64, + min_k: usize, + contract: &'static str, + scaled_contract: &'static str, +} + +const KLL_QUANTILE_FIT: KllFit = KllFit { + constant: 2.296, + power: 0.9723, + min_k: DEFAULT_M, + contract: KLL_QUANTILE_CONTRACT, + scaled_contract: KLL_QUANTILE_SCALED_CONTRACT, +}; + +/// Measured for `k` from 32 to 4096. +const KLL_RANK_FIT: KllFit = KllFit { + constant: 2.07, + power: 0.9646, + min_k: 32, + contract: KLL_RANK_CONTRACT, + scaled_contract: KLL_RANK_SCALED_CONTRACT, +}; + +/// `s(δ)`: 1 for `δ ≥ 0.01`, and `z(1 − δ/2)/z(1 − 0.005)` below. +pub fn kll_delta_scale(delta: f64) -> f64 { + if delta >= 0.01 { + 1.0 + } else { + normal_upper_quantile(delta / 2.0) / normal_upper_quantile(0.005) + } +} + +fn kll_error( + fit: KllFit, + metric: ErrorMetric, + params: &KllParams, + delta: Option, +) -> Option { + let (k, m) = normalized_k_m(params.k, params.m); + let delta = delta.unwrap_or(DEFAULT_DELTA); + (k >= fit.min_k && m >= KLL_FIT_MIN_M && delta > 0.0 && delta < 1.0).then_some(())?; + Some(ErrorBound { + metric, + epsilon: fit.constant * kll_delta_scale(delta) / (k as f64).powf(fit.power), + delta: Some(delta), + contract: if delta < 0.01 { + fit.scaled_contract + } else { + fit.contract + }, + }) +} + +fn kll_params_for(fit: KllFit, epsilon: f64, delta: f64) -> Option { + (epsilon > 0.0 && delta > 0.0 && delta < 1.0).then_some(())?; + let k = (fit.constant * kll_delta_scale(delta) / epsilon) + .powf(1.0 / fit.power) + .ceil(); + (k <= MAX_K as f64).then(|| KllParams { + k: (k as usize).max(fit.min_k), + m: DEFAULT_M, + }) +} + +fn kll_error_for( + statistic: StatisticKind, + params: &KllParams, + delta: Option, +) -> Option { + match statistic { + StatisticKind::Quantile => kll_error(KLL_QUANTILE_FIT, ErrorMetric::Rank, params, delta), + StatisticKind::Rank => kll_error(KLL_RANK_FIT, ErrorMetric::L1, params, delta), + _ => None, + } +} + +fn kll_sizing(statistic: StatisticKind, epsilon: f64, delta: f64) -> Option { + match statistic { + StatisticKind::Quantile => kll_params_for(KLL_QUANTILE_FIT, epsilon, delta), + StatisticKind::Rank => kll_params_for(KLL_RANK_FIT, epsilon, delta), + _ => None, + } +} + +/// Bounds on one KLL state's shape after `n` updates, or after any number +/// when `n` is unknown. +struct KllShape { + /// `cap(i)`, height `i` from the top. + caps: [u32; CAPACITY_CACHE_LEN], + /// `L̂`: `L(n) + 1` once `n > k`, and 1 before. + levels: usize, + /// `R̂ = min(n, cap(0) + … + cap(L̂ − 1))`. + retained: usize, + /// `c_b = cap(L(n) − 1)`. + bottom: usize, + /// `ĉ_b = min(n, cap(L(n) − 2))`, the most the bottom level holds. + bottom_max: usize, +} + +impl KllShape { + fn new(params: &KllParams, n: Option) -> Self { + let (k, m) = normalized_k_m(params.k, params.m); + let caps = capacity_table(k, m); + let cap = |height: usize| caps[height.min(CAPACITY_CACHE_LEN - 1)] as usize; + let n_or_max = n.unwrap_or(usize::MAX); + let base = levels_after(k, n_or_max); + let levels = if n_or_max > k { + (base + 1).min(MAX_LEVELS) + } else { + 1 + }; + let retained = n_or_max.min((0..levels).map(cap).sum()); + // Unknown `n` takes the largest bottom any `n` gives. + let (bottom, bottom_max) = match n { + Some(n) => (cap(base - 1), n.min(cap(base.saturating_sub(2)))), + None => (cap(0), cap(0)), + }; + Self { + caps, + levels, + retained, + bottom, + bottom_max, + } + } + + /// `Σ s·lg s` over the levels, each `s` at most its capacity and `n`. + fn level_sort_ops(&self, n: Option) -> f64 { + let n = n.unwrap_or(usize::MAX); + (0..self.levels) + .map(|height| { + let size = (self.caps[height.min(CAPACITY_CACHE_LEN - 1)] as usize).min(n) as f64; + size * lg(size) + }) + .sum() + } +} + +/// `L̂`, the most levels one KLL state holds after `n` updates. +pub(crate) fn kll_levels(params: &KllParams, n: Option) -> usize { + KllShape::new(params, n.map(|n| usize::try_from(n).unwrap_or(usize::MAX))).levels +} + +/// `3R + R·lg R + 2L + lg R + 2`: build the CDF and binary-search it. +fn kll_quantile_ops(shape: &KllShape) -> f64 { + let (r, l) = (shape.retained as f64, shape.levels as f64); + 3.0 * r + r * lg(r) + 2.0 * l + lg(r) + 2.0 +} + +fn kll_query_ops(statistic: StatisticKind, params: &KllParams, data: &StreamData) -> Option { + let shape = KllShape::new(params, data.n()); + match statistic { + StatisticKind::Quantile => Some(kll_quantile_ops(&shape)), + StatisticKind::Rank => Some(shape.retained as f64 + 2.0 * shape.levels as f64), + _ => None, + } +} + +/// `R`, `L` of a merge of two states of `n` updates each. +fn kll_merge_sizes(params: &KllParams, n: Option) -> (KllShape, f64, f64) { + let shape = KllShape::new(params, n); + let merged = KllShape::new(params, n.map(|n| n.saturating_mul(2))); + let r = 2.0 * shape.retained as f64; + (shape, r, merged.levels as f64) +} + +impl SketchAnalytics for Kll { + type Params = KllParams; + type Data = StreamData; + + fn theoretical_memory_bytes(params: &KllParams, data: &StreamData) -> Option { + Some(Kll::::theoretical_memory_bytes( + params.k, + params.m, + data.n().unwrap_or(usize::MAX), + )) + } + + fn theoretical_update_ops(params: &KllParams, data: &StreamData) -> f64 { + 15.0 + lg(KllShape::new(params, data.n()).bottom as f64 + 1.0) + } + + fn theoretical_query_ops( + statistic: StatisticKind, + params: &KllParams, + data: &StreamData, + ) -> Option { + kll_query_ops(statistic, params, data) + } + + fn theoretical_merge_ops(params: &KllParams, data: &StreamData) -> Option { + let (shape, r, l) = kll_merge_sizes(params, data.n()); + let b = 2.0 * shape.bottom_max as f64; + Some(b * lg(b) + 16.0 * r + 8.0 * l + 40.0) + } + + fn theoretical_error( + statistic: StatisticKind, + params: &KllParams, + _: &StreamData, + delta: Option, + ) -> Option { + kll_error_for(statistic, params, delta) + } + + fn params_for( + statistic: StatisticKind, + epsilon: f64, + delta: f64, + _: &StreamData, + ) -> Option { + kll_sizing(statistic, epsilon, delta) + } +} + +impl SketchAnalytics for KllDynamic { + type Params = KllParams; + type Data = StreamData; + + /// `size_of::() + size_of::()·max(3k, 2(R̂ + 1)) + 16·(L̂ + 1)`. + fn theoretical_memory_bytes(params: &KllParams, data: &StreamData) -> Option { + let (k, _) = normalized_k_m(params.k, params.m); + let shape = KllShape::new(params, data.n()); + let items = (3 * k).max(2 * (shape.retained.saturating_add(1))); + Some( + size_of::() + .saturating_add(items.saturating_mul(size_of::())) + .saturating_add(2 * (shape.levels + 1) * size_of::()), + ) + } + + fn theoretical_update_ops(params: &KllParams, data: &StreamData) -> f64 { + 11.0 + 2.0 * lg(KllShape::new(params, data.n()).bottom as f64 + 1.0) + } + + fn theoretical_query_ops( + statistic: StatisticKind, + params: &KllParams, + data: &StreamData, + ) -> Option { + kll_query_ops(statistic, params, data) + } + + fn theoretical_merge_ops(params: &KllParams, data: &StreamData) -> Option { + let (shape, r, l) = kll_merge_sizes(params, data.n()); + Some(2.0 * shape.level_sort_ops(data.n()) + 16.0 * r + 8.0 * l) + } + + fn theoretical_error( + statistic: StatisticKind, + params: &KllParams, + _: &StreamData, + delta: Option, + ) -> Option { + kll_error_for(statistic, params, delta) + } + + fn params_for( + statistic: StatisticKind, + epsilon: f64, + delta: f64, + _: &StreamData, + ) -> Option { + kll_sizing(statistic, epsilon, delta) + } +} + +/// `α`, DdSketch's relative accuracy. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct DdParams { + pub alpha: f64, +} + +impl Default for DdParams { + fn default() -> Self { + Self { alpha: 0.01 } + } +} + +/// DdSketch's data inputs. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct DdData { + pub n: Option, + /// The smallest nonzero magnitude of the values. + pub min_magnitude: Option, + /// `A`, the largest magnitude of the values. + pub max_magnitude: Option, + /// Whether negative values occur. + pub negatives: Option, +} + +const DD_QUANTILE_CONTRACT: &str = "ddsketch_relative_error_alpha_v1"; +const DD_INTERPOLATED_RELATIVE_CONTRACT: &str = "ddsketch_interpolated_relative_alpha_v1"; +const DD_INTERPOLATED_ABSOLUTE_CONTRACT: &str = "ddsketch_interpolated_absolute_alpha_v1"; + +/// The largest `α` sizing returns. +const DD_MAX_ALPHA: f64 = 0.5; + +/// Whether `α` is in (0, 1) and the known magnitudes are nonnegative and +/// within `ddsketch_indexable_bounds(α)`. +fn dd_valid(alpha: f64, data: &DdData) -> bool { + if !(alpha > 0.0 && alpha < 1.0) { + return false; + } + let (low, high) = ddsketch_indexable_bounds(alpha); + data.min_magnitude.is_none_or(|min| min >= low) + && data + .max_magnitude + .is_none_or(|max| max >= 0.0 && max <= high) +} + +/// `B`, the most bucket slots both stores of one state hold. +fn dd_slots(params: &DdParams, data: &DdData) -> f64 { + match ::theoretical_memory_bytes(params, data) { + Some(bytes) if bytes < usize::MAX => { + ((bytes - size_of::()) / size_of::()) as f64 + } + _ => f64::INFINITY, + } +} + +impl SketchAnalytics for DdSketch { + type Params = DdParams; + type Data = DdData; + + fn theoretical_memory_bytes(params: &DdParams, data: &DdData) -> Option { + (params.alpha > 0.0 && params.alpha < 1.0).then_some(())?; + Some(DdSketch::theoretical_memory_bytes( + params.alpha, + data.n + .map_or(usize::MAX, |n| usize::try_from(n).unwrap_or(usize::MAX)), + data.min_magnitude.unwrap_or(0.0), + data.max_magnitude.unwrap_or(f64::INFINITY), + data.negatives.unwrap_or(true), + )) + } + + fn theoretical_update_ops(_: &DdParams, _: &DdData) -> f64 { + 4.0 + } + + fn theoretical_query_ops( + statistic: StatisticKind, + params: &DdParams, + data: &DdData, + ) -> Option { + let b = dd_slots(params, data); + match statistic { + StatisticKind::Quantile => Some(b + 4.0), + StatisticKind::QuantileInterpolated => Some(2.0 * b + 7.0), + _ => None, + } + } + + fn theoretical_merge_ops(params: &DdParams, data: &DdData) -> Option { + Some(4.0 * dd_slots(params, data) + 11.0) + } + + fn theoretical_error( + statistic: StatisticKind, + params: &DdParams, + data: &DdData, + delta: Option, + ) -> Option { + dd_valid(params.alpha, data).then_some(())?; + let delta = Some(fixed_delta(0.0, delta)?); + let relative = |contract| ErrorBound { + metric: ErrorMetric::Relative, + epsilon: params.alpha, + delta, + contract, + }; + match statistic { + StatisticKind::Quantile => Some(relative(DD_QUANTILE_CONTRACT)), + StatisticKind::QuantileInterpolated if data.negatives == Some(false) => { + Some(relative(DD_INTERPOLATED_RELATIVE_CONTRACT)) + } + StatisticKind::QuantileInterpolated => Some(ErrorBound { + metric: ErrorMetric::ABSOLUTE, + epsilon: params.alpha * data.max_magnitude?, + delta, + contract: DD_INTERPOLATED_ABSOLUTE_CONTRACT, + }), + _ => None, + } + } + + fn params_for( + statistic: StatisticKind, + epsilon: f64, + _: f64, + data: &DdData, + ) -> Option { + is_positive(epsilon).then_some(())?; + let alpha = match statistic { + StatisticKind::Quantile => epsilon, + StatisticKind::QuantileInterpolated if data.negatives == Some(false) => epsilon, + StatisticKind::QuantileInterpolated => epsilon / data.max_magnitude?, + _ => return None, + }; + let alpha = alpha.min(DD_MAX_ALPHA); + dd_valid(alpha, data).then_some(DdParams { alpha }) + } +} + +#[cfg(feature = "experimental")] +pub use uniform::UniformParams; + +#[cfg(feature = "experimental")] +mod uniform { + use super::{SketchAnalytics, StatisticKind, StreamData, lg}; + use crate::UniformSampling; + + /// `ρ`, the sample rate. + #[derive(Debug, Clone, Copy, PartialEq)] + pub struct UniformParams { + pub sample_rate: f64, + } + + impl Default for UniformParams { + fn default() -> Self { + Self { sample_rate: 0.1 } + } + } + + /// `s = ⌈ρ·n⌉`, the sample size after `n` updates; infinite when `n` is + /// unknown. + fn sample_size(params: &UniformParams, data: &StreamData) -> f64 { + data.n + .map_or(f64::INFINITY, |n| (params.sample_rate * n as f64).ceil()) + } + + impl SketchAnalytics for UniformSampling { + type Params = UniformParams; + type Data = StreamData; + + /// `size_of::() + 16·max(4, 2s)`. + fn theoretical_memory_bytes(params: &UniformParams, data: &StreamData) -> Option { + data.n?; + let slots = (2.0 * sample_size(params, data)).max(4.0); + let bytes = size_of::() as f64 + 16.0 * slots; + Some(if bytes >= usize::MAX as f64 { + usize::MAX + } else { + bytes as usize + }) + } + + fn theoretical_update_ops(params: &UniformParams, data: &StreamData) -> f64 { + let s = sample_size(params, data); + 3.0 + lg(s) + 2.0 * s + } + + fn theoretical_query_ops( + statistic: StatisticKind, + params: &UniformParams, + data: &StreamData, + ) -> Option { + (statistic == StatisticKind::Sample).then(|| 2.0 * sample_size(params, data)) + } + + fn theoretical_merge_ops(params: &UniformParams, data: &StreamData) -> Option { + let both = 2.0 * sample_size(params, data); + Some(3.0 + 2.0 * both + both * lg(both)) + } + + fn theoretical_error( + _: StatisticKind, + _: &UniformParams, + _: &StreamData, + _: Option, + ) -> Option { + None + } + + fn params_for(_: StatisticKind, _: f64, _: f64, _: &StreamData) -> Option { + None + } + } +} diff --git a/src/analytics/top_k.rs b/src/analytics/top_k.rs new file mode 100644 index 00000000..fd115910 --- /dev/null +++ b/src/analytics/top_k.rs @@ -0,0 +1,553 @@ +//! Analytics of the counter matrices with top-k: Count-Min and Count Sketch +//! with a heap, with a Space-Saving summary, and folded. + +use super::count_matrix::{ + COUNT_MIN_CONTRACT, COUNT_SKETCH_CONTRACT, MatrixParams, TWO_POW_MINUS_32, + collision_probability, count_min_error, count_min_params_for, count_sketch_error, + count_sketch_params_for, +}; +use super::heavy_hitters::FlowData; +use super::{ + DEFAULT_DELTA, ErrorBound, HashPath, SketchAnalytics, StatisticKind, est_count_min, + est_count_sketch, fixed_delta, heap_update_ops, is_positive, max_row_failure, sort_ops, +}; +use crate::common::heap::DEFAULT_HEAP_SIZE; +use crate::common::structures::fixed_structure::{DEFAULT_COLS, DEFAULT_ROWS}; +use crate::count_min_fold::MAX_FULL_COLS; +use crate::{ + CountMinSketchFold, CountMinSketchWithHeap, CountMinSketchWithSpaceSaving, CountSketchFold, + CountSketchWithHeap, CountSketchWithSpaceSaving, DataInput, FoldCell, FoldEntry, HeapCount, + HeapCounter, MATRIX_MAX_ROWS, MatrixStorage, SPACE_SAVING_DEFAULT_CAPACITY, SketchHasher, + SpaceSaving, TopKHeap, +}; + +/// `d` rows, `w` columns and heap capacity `k`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MatrixHeapParams { + pub rows: usize, + pub cols: usize, + pub heap_size: usize, +} + +impl Default for MatrixHeapParams { + fn default() -> Self { + Self { + rows: DEFAULT_ROWS, + cols: DEFAULT_COLS, + heap_size: DEFAULT_HEAP_SIZE, + } + } +} + +/// `d` rows, `w` columns and `m` Space-Saving counters. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MatrixSpaceSavingParams { + pub rows: usize, + pub cols: usize, + pub capacity: usize, +} + +impl Default for MatrixSpaceSavingParams { + fn default() -> Self { + Self { + rows: DEFAULT_ROWS, + cols: DEFAULT_COLS, + capacity: SPACE_SAVING_DEFAULT_CAPACITY, + } + } +} + +/// `d` rows, full width `W`, fold level `F` and heap capacity `k`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FoldParams { + pub rows: usize, + pub full_cols: usize, + pub fold_level: u32, + pub heap_size: usize, +} + +impl Default for FoldParams { + fn default() -> Self { + Self { + rows: DEFAULT_ROWS, + full_cols: DEFAULT_COLS, + fold_level: 3, + heap_size: DEFAULT_HEAP_SIZE, + } + } +} + +/// The data inputs of CountMinSketchFold and CountSketchFold. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct FoldData { + /// `D`, the distinct keys. + pub distinct: Option, + /// `E`, the most entries one cell holds. Unknown counts as + /// `min(2^F, D)`. + pub max_cell_entries: Option, + /// `S`, the entries in cells holding more than one. Unknown counts as + /// `d·min(W, D)`. + pub collided_entries: Option, + /// Bytes each key owns: a string's or byte slice's length, 0 for a + /// number. + pub key_bytes: Option, + /// Whether `merge_same_level` has merged into the state. Unknown counts + /// as merged. + pub merged: Option, +} + +/// The base sketch of a top-k matrix. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Base { + CountMin, + CountSketch, +} + +impl Base { + fn est(self, rows: usize) -> f64 { + match self { + Base::CountMin => est_count_min(rows), + Base::CountSketch => est_count_sketch(rows), + } + } +} + +/// The base sketch's ItemFrequency bound. +fn item_error( + base: Base, + shape: MatrixParams, + fast_path: bool, + delta: Option, +) -> Option { + match base { + Base::CountMin => count_min_error(&shape, fast_path, delta, COUNT_MIN_CONTRACT), + Base::CountSketch => count_sketch_error( + shape.rows, + collision_probability(shape.cols, fast_path), + delta, + COUNT_SKETCH_CONTRACT, + ), + } +} + +/// Each reported count as ItemFrequency, with the base sketch's `δ` times +/// the `keys` that could be reported; `None` without `keys` or when that +/// product is at least 1. +fn top_k_error( + base: Base, + shape: MatrixParams, + fast_path: bool, + keys: Option, + delta: Option, +) -> Option { + let keys = keys?.max(1.0); + match base { + Base::CountMin => { + let per_key = item_error(base, shape, fast_path, None)?; + let total = keys * per_key.delta?; + (total < 1.0).then_some(())?; + Some(ErrorBound { + delta: Some(fixed_delta(total, delta)?), + ..per_key + }) + } + Base::CountSketch => { + let total = delta.unwrap_or(DEFAULT_DELTA); + let per_key = item_error(base, shape, fast_path, Some(total / keys))?; + Some(ErrorBound { + delta: Some(total), + ..per_key + }) + } + } +} + +/// The base sketch's ItemFrequency sizing. +fn item_params(base: Base, epsilon: f64, delta: f64, fast_path: bool) -> Option { + match base { + Base::CountMin => count_min_params_for(epsilon, delta, fast_path, MATRIX_MAX_ROWS), + Base::CountSketch => count_sketch_params_for(epsilon, delta, fast_path), + } +} + +/// `params_for` of a top-k matrix: ItemFrequency as the base sketch, TopK as +/// ItemFrequency at `δ/keys`. +fn top_k_params( + base: Base, + statistic: StatisticKind, + epsilon: f64, + delta: f64, + fast_path: bool, + keys: Option, +) -> Option { + match statistic { + StatisticKind::ItemFrequency => item_params(base, epsilon, delta, fast_path), + StatisticKind::TopK => item_params(base, epsilon, delta / keys?.max(1.0), fast_path), + _ => None, + } +} + +/// [`TopKHeap::theoretical_memory_bytes`] for keys owning `key_bytes` bytes. +fn heap_memory(heap_size: usize, key_bytes: Option) -> Option { + let key = vec![0u8; key_bytes?]; + Some(TopKHeap::::theoretical_memory_bytes( + heap_size, + &DataInput::Bytes(&key), + )) +} + +fn distinct(data: Option) -> Option { + data.map(|d| d as f64) +} + +/// Keys a structure of `capacity` holds: `min(capacity, D)`. +fn held(capacity: usize, distinct: Option) -> f64 { + distinct.map_or(capacity, |d| capacity.min(d as usize)) as f64 +} + +type HeapCountOf = <::Counter as HeapCounter>::HeapCount; + +macro_rules! matrix_with_heap { + ($sketch:ident, $base:expr) => { + impl SketchAnalytics + for $sketch + where + S::Counter: HeapCounter, + { + type Params = MatrixHeapParams; + type Data = FlowData; + + fn theoretical_memory_bytes( + params: &MatrixHeapParams, + data: &FlowData, + ) -> Option { + let heap = heap_memory::>(params.heap_size, data.key_bytes)?; + Some( + (size_of::() - size_of::>>()) + .saturating_add( + params + .rows + .saturating_mul(params.cols) + .saturating_mul(size_of::()), + ) + .saturating_add(heap), + ) + } + + fn theoretical_update_ops(params: &MatrixHeapParams, _: &FlowData) -> f64 { + params.rows as f64 + $base.est(params.rows) + heap_update_ops(params.heap_size) + } + + fn theoretical_query_ops( + statistic: StatisticKind, + params: &MatrixHeapParams, + _: &FlowData, + ) -> Option { + match statistic { + StatisticKind::ItemFrequency => Some($base.est(params.rows)), + StatisticKind::TopK => Some(params.heap_size as f64), + _ => None, + } + } + + fn theoretical_merge_ops(params: &MatrixHeapParams, data: &FlowData) -> Option { + let keys = 2.0 * held(params.heap_size, data.distinct); + let per_key = 4.0 + $base.est(params.rows) + heap_update_ops(params.heap_size); + Some(2.0 * (params.rows * params.cols) as f64 + keys * per_key) + } + + fn theoretical_error( + statistic: StatisticKind, + params: &MatrixHeapParams, + data: &FlowData, + delta: Option, + ) -> Option { + let shape = MatrixParams { + rows: params.rows, + cols: params.cols, + }; + match statistic { + StatisticKind::ItemFrequency => item_error($base, shape, Mode::FAST, delta), + StatisticKind::TopK => { + top_k_error($base, shape, Mode::FAST, distinct(data.distinct), delta) + } + _ => None, + } + } + + fn params_for( + statistic: StatisticKind, + epsilon: f64, + delta: f64, + data: &FlowData, + ) -> Option { + let keys = distinct(data.distinct); + let shape = top_k_params($base, statistic, epsilon, delta, Mode::FAST, keys)?; + Some(MatrixHeapParams { + rows: shape.rows, + cols: shape.cols, + ..MatrixHeapParams::default() + }) + } + } + }; +} + +matrix_with_heap!(CountMinSketchWithHeap, Base::CountMin); +matrix_with_heap!(CountSketchWithHeap, Base::CountSketch); + +/// SpaceSaving's most ops for one unit update, merged or not. +const SPACE_SAVING_UPDATE_OPS: f64 = 47.0; + +macro_rules! matrix_with_space_saving { + ($sketch:ident, $base:expr) => { + impl SketchAnalytics + for $sketch + { + type Params = MatrixSpaceSavingParams; + type Data = FlowData; + + fn theoretical_memory_bytes( + params: &MatrixSpaceSavingParams, + data: &FlowData, + ) -> Option { + let summary = + SpaceSaving::::theoretical_memory_bytes(params.capacity, data.key_bytes?); + Some( + (size_of::() - size_of::>()) + .saturating_add( + params + .rows + .saturating_mul(params.cols) + .saturating_mul(size_of::()), + ) + .saturating_add(summary), + ) + } + + fn theoretical_update_ops(params: &MatrixSpaceSavingParams, _: &FlowData) -> f64 { + params.rows as f64 + SPACE_SAVING_UPDATE_OPS + } + + fn theoretical_query_ops( + statistic: StatisticKind, + params: &MatrixSpaceSavingParams, + data: &FlowData, + ) -> Option { + let est = $base.est(params.rows); + match statistic { + StatisticKind::ItemFrequency => Some(est), + StatisticKind::TopK => { + let keys = held(params.capacity, data.distinct); + Some(keys * (5.0 + est) + sort_ops(keys)) + } + _ => None, + } + } + + fn theoretical_merge_ops( + params: &MatrixSpaceSavingParams, + _: &FlowData, + ) -> Option { + let m = params.capacity.max(1) as f64; + let matrix = 2.0 * (params.rows * params.cols) as f64; + Some(matrix + 44.0 * m + sort_ops(2.0 * m) + 13.0) + } + + fn theoretical_error( + statistic: StatisticKind, + params: &MatrixSpaceSavingParams, + data: &FlowData, + delta: Option, + ) -> Option { + let shape = MatrixParams { + rows: params.rows, + cols: params.cols, + }; + match statistic { + StatisticKind::ItemFrequency => item_error($base, shape, Mode::FAST, delta), + StatisticKind::TopK => { + let keys = held(params.capacity, data.distinct); + top_k_error($base, shape, Mode::FAST, Some(keys), delta) + } + _ => None, + } + } + + fn params_for( + statistic: StatisticKind, + epsilon: f64, + delta: f64, + data: &FlowData, + ) -> Option { + let sized = MatrixSpaceSavingParams::default(); + let keys = held(sized.capacity, data.distinct); + let shape = top_k_params($base, statistic, epsilon, delta, Mode::FAST, Some(keys))?; + Some(MatrixSpaceSavingParams { + rows: shape.rows, + cols: shape.cols, + ..sized + }) + } + } + }; +} + +matrix_with_space_saving!(CountMinSketchWithSpaceSaving, Base::CountMin); +matrix_with_space_saving!(CountSketchWithSpaceSaving, Base::CountSketch); + +/// `E` and `S` of a fold state, from `data` or their upper bounds. +fn fold_entries(params: &FoldParams, data: &FoldData) -> (f64, usize) { + let keys = data.distinct.map_or(usize::MAX, |d| d as usize); + let most = data + .max_cell_entries + .unwrap_or((1usize << params.fold_level).min(keys)); + let collided = data + .collided_entries + .unwrap_or(params.rows.saturating_mul(params.full_cols.min(keys))); + (most as f64, collided) +} + +fn fold_shape(params: &FoldParams) -> MatrixParams { + MatrixParams { + rows: params.rows, + cols: params.full_cols, + } +} + +/// Fold sizing: the base sketch's on `RegularPath` with `W` a power of two +/// at most 65536, and the default fold level capped at `log2 W`. +fn fold_params( + base: Base, + statistic: StatisticKind, + epsilon: f64, + delta: f64, + keys: Option, +) -> Option { + let delta = match statistic { + StatisticKind::ItemFrequency => delta, + StatisticKind::TopK => delta / keys?.max(1.0), + _ => return None, + }; + let shape = match base { + Base::CountMin => { + let shape = count_min_params_for(epsilon, delta, false, usize::MAX)?; + MatrixParams { + cols: shape.cols.next_power_of_two(), + ..shape + } + } + Base::CountSketch => fold_count_sketch_params_for(epsilon, delta)?, + }; + let sized = FoldParams::default(); + (shape.cols <= MAX_FULL_COLS).then_some(FoldParams { + rows: shape.rows, + full_cols: shape.cols, + fold_level: sized.fold_level.min(shape.cols.trailing_zeros()), + ..sized + }) +} + +/// The odd `d ≤ 19` and power-of-two `W` with `q ≤ β·ε²` on `RegularPath` +/// that minimize `d·W`. +fn fold_count_sketch_params_for(epsilon: f64, delta: f64) -> Option { + if !is_positive(epsilon) || !is_positive(delta) { + return None; + } + let mut best: Option = None; + for rows in (1..=MATRIX_MAX_ROWS).step_by(2) { + let room = max_row_failure(rows, delta) * epsilon * epsilon - TWO_POW_MINUS_32; + if !is_positive(room) || 1.0 / room > MAX_FULL_COLS as f64 { + continue; + } + let cols = ((1.0 / room).ceil() as usize).next_power_of_two(); + if best.is_none_or(|b| rows * cols < b.rows * b.cols) { + best = Some(MatrixParams { rows, cols }); + } + } + best +} + +macro_rules! fold { + ($sketch:ident, $base:expr) => { + impl SketchAnalytics for $sketch { + type Params = FoldParams; + type Data = FoldData; + + fn theoretical_memory_bytes(params: &FoldParams, data: &FoldData) -> Option { + let (_, collided) = fold_entries(params, data); + let cells = params + .rows + .saturating_mul(params.full_cols >> params.fold_level); + let heap = heap_memory::(params.heap_size, data.key_bytes)?; + Some( + (size_of::() - size_of::()) + .saturating_add(cells.saturating_mul(size_of::())) + .saturating_add(collided.saturating_mul(2 * size_of::())) + .saturating_add(heap), + ) + } + + fn theoretical_update_ops(params: &FoldParams, data: &FoldData) -> f64 { + let (most, _) = fold_entries(params, data); + let d = params.rows as f64; + 2.0 * d * most + d + $base.est(params.rows) + heap_update_ops(params.heap_size) + } + + fn theoretical_query_ops( + statistic: StatisticKind, + params: &FoldParams, + data: &FoldData, + ) -> Option { + let (most, _) = fold_entries(params, data); + match statistic { + StatisticKind::ItemFrequency => { + Some(params.rows as f64 * most + $base.est(params.rows)) + } + StatisticKind::TopK => Some(params.heap_size as f64), + _ => None, + } + } + + fn theoretical_merge_ops(params: &FoldParams, data: &FoldData) -> Option { + let (most, _) = fold_entries(params, data); + let d = params.rows as f64; + let cells = d * (params.full_cols >> params.fold_level) as f64; + let entries = d * held(params.full_cols, data.distinct); + let keys = held(params.heap_size, data.distinct); + let per_key = + 1.0 + d * most + $base.est(params.rows) + heap_update_ops(params.heap_size); + Some(cells + entries * (2.0 + most) + keys * per_key) + } + + fn theoretical_error( + statistic: StatisticKind, + params: &FoldParams, + data: &FoldData, + delta: Option, + ) -> Option { + let shape = fold_shape(params); + match statistic { + StatisticKind::ItemFrequency => item_error($base, shape, false, delta), + StatisticKind::TopK if data.merged == Some(false) => { + top_k_error($base, shape, false, distinct(data.distinct), delta) + } + _ => None, + } + } + + fn params_for( + statistic: StatisticKind, + epsilon: f64, + delta: f64, + data: &FoldData, + ) -> Option { + (statistic != StatisticKind::TopK || data.merged == Some(false)).then_some(())?; + fold_params($base, statistic, epsilon, delta, distinct(data.distinct)) + } + } + }; +} + +fold!(CountMinSketchFold, Base::CountMin); +fold!(CountSketchFold, Base::CountSketch); diff --git a/src/analytics/univmon.rs b/src/analytics/univmon.rs new file mode 100644 index 00000000..a2314d14 --- /dev/null +++ b/src/analytics/univmon.rs @@ -0,0 +1,585 @@ +//! Analytics of the UnivMon family: UnivMon, UnivMonTwoTier and UnivMonQ. + +use super::count_matrix::{ + L2_NORM_CONTRACT, MatrixParams, count_sketch_error, l2_norm_error, l2_norm_params_for, +}; +use super::heavy_hitters::FlowData; +use super::{ + DEFAULT_DELTA, ErrorBound, ErrorMetric, SketchAnalytics, StatisticKind, est_count_sketch, + floor_log2, heap_update_ops, is_positive, lg, max_row_failure, +}; +use crate::common::heap::DEFAULT_HEAP_SIZE; +use crate::sketch_framework::univmon::{UNIVMON_MAX_LAYERS, wire::check_univmon_dimensions}; +use crate::sketch_framework::univmon_q::{config_is_valid, layer_cols}; +use crate::sketch_framework::{univmon, univmon_two_tier}; +use crate::{ + CountSketchWithL2Norm, DataInput, SketchHasher, UnivMon, UnivMonQ, UnivMonQConfig, + UnivMonTwoTier, +}; +use std::f64::consts::E; + +/// Heap capacity `k`, `d` rows, `w` columns and `L` layers of a UnivMon. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UnivMonParams { + pub heap_size: usize, + pub rows: usize, + pub cols: usize, + pub layers: usize, +} + +impl Default for UnivMonParams { + fn default() -> Self { + Self { + heap_size: DEFAULT_HEAP_SIZE, + rows: univmon::DEFAULT_ROWS, + cols: univmon::DEFAULT_COLS, + layers: univmon::DEFAULT_LAYERS, + } + } +} + +/// Heap capacity `k`, `L_e` elephant layers of `d_e × w_e`, mouse layers of +/// `d_m × w_m`, and `L` layers of a UnivMonTwoTier. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TwoTierParams { + pub heap_size: usize, + pub elephant_layers: usize, + pub elephant_rows: usize, + pub elephant_cols: usize, + pub mouse_rows: usize, + pub mouse_cols: usize, + pub layers: usize, +} + +impl Default for TwoTierParams { + fn default() -> Self { + Self { + heap_size: DEFAULT_HEAP_SIZE, + elephant_layers: univmon_two_tier::DEFAULT_ELEPHANT_LAYERS, + elephant_rows: univmon_two_tier::DEFAULT_ELEPHANT_ROWS, + elephant_cols: univmon_two_tier::DEFAULT_ELEPHANT_COLS, + mouse_rows: univmon_two_tier::DEFAULT_MOUSE_ROWS, + mouse_cols: univmon_two_tier::DEFAULT_MOUSE_COLS, + layers: univmon_two_tier::DEFAULT_LAYERS, + } + } +} + +impl TwoTierParams { + /// `(d, w)` of layer `j`. + fn layer(&self, j: usize) -> (usize, usize) { + if j < self.elephant_layers { + (self.elephant_rows, self.elephant_cols) + } else { + (self.mouse_rows, self.mouse_cols) + } + } +} + +/// The data inputs of UnivMonQ. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct UnivMonQData { + /// `n`, the updates. + pub n: Option, + /// `F̂₂`, an upper bound on the state's + /// [`estimate_f2`](crate::UnivMonQ::estimate_f2). The ordered readouts' + /// heavy set is empty when `F̂₂·M < n²`. + pub f2_estimate: Option, +} + +const TOTAL_CONTRACT: &str = "univmon_unit_update_total"; +const DISTINCT_CONTRACT: &str = "univmon_distinct_complete_top_layer_chebyshev_v1"; +const ITEM_CONTRACT: &str = "univmonq_terminal_count_sketch_l2_median_chosen_row_rate_v1"; +const SAMPLE_CONTRACT: &str = "univmonq_rank_diffuse_occurrence_hoeffding_v1"; + +/// The heap estimator's variance, in units of `D²/k`. +const DISTINCT_VARIANCE: f64 = 1.35; + +/// The smallest heap the Distinct bound holds for. +const DISTINCT_MIN_HEAP: usize = 8; + +/// `λ(L) = 2 − 2^(1−L)`, the layers an update reaches on average. +fn reached_layers(layers: usize) -> f64 { + 2.0 - 2f64.powi(1 - layers as i32) +} + +/// `ψ`: 0 when `D ≤ k`, `(e/4)^(k/2)` when `L ≥ lg(D/k) + 2`, else 1. +fn overflow_probability(heap_size: usize, layers: usize, distinct: u64) -> f64 { + let ratio = distinct as f64 / heap_size as f64; + if ratio <= 1.0 { + 0.0 + } else if layers as f64 >= lg(ratio) + 2.0 { + (E / 4.0).powf(heap_size as f64 / 2.0) + } else { + 1.0 + } +} + +/// Distinct: `Relative`, `ε = √(1.35/(k·(δ − ψ)))`, `δ` chosen, `k ≥ 8`; +/// `None` without `D` or when `δ ≤ ψ`. +fn distinct_error( + heap_size: usize, + layers: usize, + data: &FlowData, + delta: Option, +) -> Option { + (heap_size >= DISTINCT_MIN_HEAP).then_some(())?; + let delta = delta.unwrap_or(DEFAULT_DELTA); + let room = delta - overflow_probability(heap_size, layers, data.distinct?); + (room > 0.0).then(|| ErrorBound { + metric: ErrorMetric::Relative, + epsilon: (DISTINCT_VARIANCE / (heap_size as f64 * room)).sqrt(), + delta: Some(delta), + contract: DISTINCT_CONTRACT, + }) +} + +/// Distinct sizing: the smallest `k ≥ 8` with +/// `1.35/(k·ε²) + (e/4)^(k/2) ≤ δ`, and `L = lg(D/k) + 2`; `None` past +/// [`UNIVMON_MAX_LAYERS`]. +fn distinct_params_for(epsilon: f64, delta: f64, data: &FlowData) -> Option<(usize, usize)> { + if !is_positive(epsilon) || !is_positive(delta) { + return None; + } + let distinct = data.distinct?; + let fails = |k: usize| { + let k = k as f64; + DISTINCT_VARIANCE / (k * epsilon * epsilon) + (E / 4.0).powf(k / 2.0) > delta + }; + let mut high = DISTINCT_MIN_HEAP; + while fails(high) { + high = high.checked_mul(2).filter(|&k| k <= u32::MAX as usize)?; + } + let mut low = DISTINCT_MIN_HEAP; + while low < high { + let mid = low + (high - low) / 2; + if fails(mid) { + low = mid + 1; + } else { + high = mid; + } + } + let layers = lg(distinct as f64 / low as f64) as usize + 2; + (layers <= UNIVMON_MAX_LAYERS).then_some((low, layers)) +} + +/// `k` capped by `D`: the most keys one layer's heap holds. +fn heap_keys(heap_size: usize, data: &FlowData) -> f64 { + data.distinct + .map_or(heap_size as f64, |d| (d as f64).min(heap_size as f64)) +} + +/// One layer's share of an update: `3d` counter and row-sum ops, an +/// estimate, a heap update and the candidate flag. +fn layer_update_ops(rows: usize, heap_size: usize) -> f64 { + 3.0 * rows as f64 + est_count_sketch(rows) + heap_update_ops(heap_size) + 1.0 +} + +/// One layer's share of a merge: the counters, the two flags, both heaps +/// read into a set, the heap's `c` residents cleared, each of up to `2c` keys +/// re-estimated into it, and the flag written. +fn layer_merge_ops(rows: usize, cols: usize, heap_size: usize, keys: f64) -> f64 { + (3 * rows * cols + rows) as f64 + + 3.0 + + keys * (6.0 + 2.0 * (1.0 + est_count_sketch(rows) + heap_update_ops(heap_size))) +} + +/// The readouts both pyramids share, from layer 0's dimensions. +fn pyramid_error( + statistic: StatisticKind, + layer0: MatrixParams, + heap_size: usize, + layers: usize, + data: &FlowData, + delta: Option, +) -> Option { + match statistic { + StatisticKind::Total => Some(ErrorBound { + contract: TOTAL_CONTRACT, + ..ErrorBound::EXACT + }), + StatisticKind::FrequencyL2 => l2_norm_error(&layer0, delta, L2_NORM_CONTRACT), + StatisticKind::Distinct => distinct_error(heap_size, layers, data, delta), + _ => None, + } +} + +/// The query ops both pyramids share; `layer_rows` gives each layer's `d`. +fn pyramid_query_ops( + statistic: StatisticKind, + heap_size: usize, + layers: usize, + layer_rows: impl Fn(usize) -> usize, + data: &FlowData, +) -> Option { + match statistic { + StatisticKind::Total => Some(1.0), + StatisticKind::FrequencyL2 => Some(est_count_sketch(layer_rows(0))), + StatisticKind::Distinct | StatisticKind::GSum | StatisticKind::FrequencyEntropy => { + let keys = heap_keys(heap_size, data); + let reads: f64 = (0..layers) + .map(|j| 1.0 + est_count_sketch(layer_rows(j))) + .sum(); + Some(4.0 + 2.0 * layers as f64 + keys * reads) + } + _ => None, + } +} + +impl SketchAnalytics for UnivMon { + type Params = UnivMonParams; + type Data = FlowData; + + fn theoretical_memory_bytes(params: &UnivMonParams, data: &FlowData) -> Option { + let key = vec![0u8; data.key_bytes?]; + Some(UnivMon::theoretical_memory_bytes( + params.heap_size, + params.rows, + params.cols, + params.layers, + &DataInput::Bytes(&key), + )) + } + + fn theoretical_update_ops(params: &UnivMonParams, _: &FlowData) -> f64 { + 1.0 + reached_layers(params.layers) * layer_update_ops(params.rows, params.heap_size) + } + + fn theoretical_query_ops( + statistic: StatisticKind, + params: &UnivMonParams, + data: &FlowData, + ) -> Option { + pyramid_query_ops( + statistic, + params.heap_size, + params.layers, + |_| params.rows, + data, + ) + } + + fn theoretical_merge_ops(params: &UnivMonParams, data: &FlowData) -> Option { + let keys = heap_keys(params.heap_size, data); + let layer = layer_merge_ops(params.rows, params.cols, params.heap_size, keys); + Some(2.0 + params.layers as f64 * layer) + } + + fn theoretical_error( + statistic: StatisticKind, + params: &UnivMonParams, + data: &FlowData, + delta: Option, + ) -> Option { + let layer0 = MatrixParams { + rows: params.rows, + cols: params.cols, + }; + pyramid_error( + statistic, + layer0, + params.heap_size, + params.layers, + data, + delta, + ) + } + + fn params_for( + statistic: StatisticKind, + epsilon: f64, + delta: f64, + data: &FlowData, + ) -> Option { + let params = match statistic { + StatisticKind::Total => UnivMonParams::default(), + StatisticKind::FrequencyL2 => { + let shape = l2_norm_params_for(epsilon, delta)?; + UnivMonParams { + rows: shape.rows, + cols: shape.cols, + ..UnivMonParams::default() + } + } + StatisticKind::Distinct => { + let (heap_size, layers) = distinct_params_for(epsilon, delta, data)?; + UnivMonParams { + heap_size, + layers, + ..UnivMonParams::default() + } + } + _ => return None, + }; + UnivMon::check_shape(params.heap_size, params.rows, params.cols, params.layers) + .ok() + .map(|()| params) + } +} + +impl SketchAnalytics for UnivMonTwoTier { + type Params = TwoTierParams; + type Data = FlowData; + + fn theoretical_memory_bytes(params: &TwoTierParams, data: &FlowData) -> Option { + let key = vec![0u8; data.key_bytes?]; + Some(UnivMonTwoTier::theoretical_memory_bytes( + params.heap_size, + params.elephant_layers, + params.elephant_rows, + params.elephant_cols, + params.mouse_rows, + params.mouse_cols, + params.layers, + &DataInput::Bytes(&key), + )) + } + + fn theoretical_update_ops(params: &TwoTierParams, _: &FlowData) -> f64 { + let elephant = reached_layers(params.elephant_layers.min(params.layers)); + let mouse = reached_layers(params.layers) - elephant; + 1.0 + elephant * layer_update_ops(params.elephant_rows, params.heap_size) + + mouse * layer_update_ops(params.mouse_rows, params.heap_size) + } + + fn theoretical_query_ops( + statistic: StatisticKind, + params: &TwoTierParams, + data: &FlowData, + ) -> Option { + pyramid_query_ops( + statistic, + params.heap_size, + params.layers, + |j| params.layer(j).0, + data, + ) + } + + fn theoretical_merge_ops(params: &TwoTierParams, data: &FlowData) -> Option { + let keys = heap_keys(params.heap_size, data); + let layers: f64 = (0..params.layers) + .map(|j| { + let (rows, cols) = params.layer(j); + layer_merge_ops(rows, cols, params.heap_size, keys) + }) + .sum(); + Some(2.0 + layers) + } + + fn theoretical_error( + statistic: StatisticKind, + params: &TwoTierParams, + data: &FlowData, + delta: Option, + ) -> Option { + let (rows, cols) = params.layer(0); + pyramid_error( + statistic, + MatrixParams { rows, cols }, + params.heap_size, + params.layers, + data, + delta, + ) + } + + fn params_for( + statistic: StatisticKind, + epsilon: f64, + delta: f64, + data: &FlowData, + ) -> Option { + let params = match statistic { + StatisticKind::Total => TwoTierParams::default(), + StatisticKind::FrequencyL2 => { + let shape = l2_norm_params_for(epsilon, delta)?; + TwoTierParams { + elephant_rows: shape.rows, + elephant_cols: shape.cols, + ..TwoTierParams::default() + } + } + StatisticKind::Distinct => { + let (heap_size, layers) = distinct_params_for(epsilon, delta, data)?; + TwoTierParams { + heap_size, + layers, + ..TwoTierParams::default() + } + } + _ => return None, + }; + let fits = (0..params.layers).all(|j| { + let (rows, cols) = params.layer(j); + check_univmon_dimensions(params.layers, rows, cols, params.heap_size).is_ok() + && CountSketchWithL2Norm::::check_hash_bits(rows, cols) + .is_ok() + }); + (fits && params.layers <= UNIVMON_MAX_LAYERS).then_some(params) + } +} + +/// `q` of UnivMonQ: the most collision probability over its layers, `1/w_j` +/// for a power-of-two `w_j` and `min(1/w_j + 2^(−lg W), 9/(8w_j))` otherwise. +fn univmonq_collision_probability(config: &UnivMonQConfig) -> f64 { + let slice = 2f64.powi(-(lg(config.top_layer_cols as f64) as i32)); + (0..config.layers) + .map(|level| { + let cols = layer_cols(*config, level); + let w = cols as f64; + if cols.is_power_of_two() { + 1.0 / w + } else { + (1.0 / w + slice).min(9.0 / (8.0 * w)) + } + }) + .fold(0.0, f64::max) +} + +/// Whether `F̂₂·M < n²`, which empties the ordered readouts' heavy set. +fn heavy_set_empty(samples: usize, data: &UnivMonQData) -> Option { + let n = data.n? as f64; + let f2 = data.f2_estimate?; + Some(f2 >= 0.0 && f2 * (samples as f64) < n * n) +} + +/// Quantile and Rank: `ε = √(ln(2/δ)/(2·min(M, n))) + 1/n`, `δ` chosen, +/// when `F̂₂·M < n²`. +fn sample_error( + metric: ErrorMetric, + config: &UnivMonQConfig, + data: &UnivMonQData, + delta: Option, +) -> Option { + (config.ordered_samples > 0 && heavy_set_empty(config.ordered_samples, data)?).then_some(())?; + let n = data.n? as f64; + let sampled = (config.ordered_samples as f64).min(n); + let delta = delta.unwrap_or(DEFAULT_DELTA); + (sampled > 0.0 && delta > 0.0 && delta <= 1.0).then(|| ErrorBound { + metric, + epsilon: ((2.0 / delta).ln() / (2.0 * sampled)).sqrt() + 1.0 / n, + delta: Some(delta), + contract: SAMPLE_CONTRACT, + }) +} + +/// ItemFrequency sizing: the odd `d` and power-of-two `W`, with no halving, +/// that meet `(ε, δ)` with the fewest counters `d·W`. +fn univmonq_item_params_for(epsilon: f64, delta: f64) -> Option { + if !is_positive(epsilon) || !is_positive(delta) { + return None; + } + let mut best: Option = None; + for rows in (1..=63).step_by(2) { + let target = max_row_failure(rows, delta) * epsilon * epsilon; + if !is_positive(target) || 1.0 / target > (1u64 << 32) as f64 { + continue; + } + let config = UnivMonQConfig { + rows, + top_layer_cols: ((1.0 / target).ceil() as usize).next_power_of_two(), + cols_halving_period: 0, + ..UnivMonQConfig::default() + }; + if config_is_valid(config) + && best.is_none_or(|b| rows * config.top_layer_cols < b.rows * b.top_layer_cols) + { + best = Some(config); + } + } + best +} + +impl SketchAnalytics for UnivMonQ { + type Params = UnivMonQConfig; + type Data = UnivMonQData; + + fn theoretical_memory_bytes(config: &UnivMonQConfig, _: &UnivMonQData) -> Option { + Some(UnivMonQ::::theoretical_memory_bytes(*config)) + } + + fn theoretical_update_ops(config: &UnivMonQConfig, _: &UnivMonQData) -> f64 { + let d = config.rows as f64; + let k = config.heap_size as f64; + let levels = floor_log2(config.heap_size); + let sample = if config.ordered_samples > 0 { + 11.0 + 7.0 * floor_log2(config.ordered_samples) + } else { + 0.0 + }; + 21.0 + d + est_count_sketch(config.rows) + 7.0 * levels + k * (12.0 + 7.0 * levels) + sample + } + + fn theoretical_query_ops( + statistic: StatisticKind, + config: &UnivMonQConfig, + _: &UnivMonQData, + ) -> Option { + (statistic == StatisticKind::ItemFrequency).then(|| 1.0 + est_count_sketch(config.rows)) + } + + fn theoretical_merge_ops(config: &UnivMonQConfig, _: &UnivMonQData) -> Option { + let d = config.rows as f64; + let k = config.heap_size as f64; + let m = config.ordered_samples as f64; + let counters: f64 = (0..config.layers) + .map(|level| 2.0 * d * layer_cols(*config, level) as f64) + .sum(); + let per_key = 15.0 + + 2.0 * est_count_sketch(config.rows) + + 2.0 * lg(k) + + 2.0 * floor_log2(config.heap_size); + let layers = config.layers as f64 * (4.0 + k * per_key); + let samples = m * (12.0 + 7.0 * floor_log2(config.ordered_samples)); + Some(6.0 + counters + layers + samples) + } + + fn theoretical_error( + statistic: StatisticKind, + config: &UnivMonQConfig, + data: &UnivMonQData, + delta: Option, + ) -> Option { + match statistic { + StatisticKind::ItemFrequency => count_sketch_error( + config.rows, + univmonq_collision_probability(config), + delta, + ITEM_CONTRACT, + ), + StatisticKind::Quantile => sample_error(ErrorMetric::Rank, config, data, delta), + StatisticKind::Rank => sample_error(ErrorMetric::L1, config, data, delta), + _ => None, + } + } + + fn params_for( + statistic: StatisticKind, + epsilon: f64, + delta: f64, + data: &UnivMonQData, + ) -> Option { + match statistic { + StatisticKind::ItemFrequency => univmonq_item_params_for(epsilon, delta), + StatisticKind::Quantile | StatisticKind::Rank => { + let room = epsilon - 1.0 / data.n? as f64; + if !is_positive(room) || !is_positive(delta) { + return None; + } + let samples = ((2.0 / delta).ln() / (2.0 * room * room)).ceil(); + if !samples.is_finite() || samples > u32::MAX as f64 { + return None; + } + let samples = (samples as usize).max(1); + (data.n? >= samples as u64 && heavy_set_empty(samples, data)?).then(|| { + UnivMonQConfig { + ordered_samples: samples, + ..UnivMonQConfig::default() + } + }) + } + _ => None, + } + } +} diff --git a/src/capability.rs b/src/capability.rs index c4d5a27d..f7308abc 100644 --- a/src/capability.rs +++ b/src/capability.rs @@ -17,6 +17,7 @@ )] pub enum SketchAlgorithm { /// `CountMinSketch` over every storage and path, and `CountMinF64`. + /// Total is exact unless Nitro sampling is enabled. CountMinSketch, CountSketch, CountSketchWithL2Norm, @@ -77,6 +78,9 @@ pub enum StatisticKind { Total, ItemFrequency, Quantile, + /// The quantile interpolated between the order statistics at rank + /// `q·(n − 1)`. + QuantileInterpolated, /// Absolute on the KLLs and UnivMonQ, normalized to `[0, 1]` on Hydra. Rank, Distinct, @@ -199,7 +203,7 @@ impl SketchAlgorithm { ]; const FREQUENCY_TOP_K: &[(StatisticKind, MergedError)] = &[(ItemFrequency, U), (TopK, A)]; let (merge_inputs, readouts): (_, &'static [(StatisticKind, MergedError)]) = match self { - Self::CountMinSketch => (Some(Disjoint), &[(ItemFrequency, U), (Total, E)]), + Self::CountMinSketch => (Some(Disjoint), &[(ItemFrequency, U), (Total, U)]), Self::CountSketch => (Some(Disjoint), &[(ItemFrequency, U)]), Self::CountSketchWithL2Norm => { (Some(Disjoint), &[(ItemFrequency, U), (FrequencyL2, U)]) @@ -214,7 +218,7 @@ impl SketchAlgorithm { Self::HLLHip => (None, &[(Distinct, A)]), Self::KMV => (Some(MayOverlap), &[(Distinct, U)]), Self::KLL | Self::KLLDynamic => (Some(Disjoint), &[(Quantile, U), (Rank, U)]), - Self::DDSketch => (Some(Disjoint), &[(Quantile, U)]), + Self::DDSketch => (Some(Disjoint), &[(Quantile, U), (QuantileInterpolated, U)]), Self::SpaceSaving => (Some(Disjoint), &[(ItemFrequency, A), (TopK, A)]), Self::ElasticSketch => (Some(Disjoint), &[(ItemFrequency, A), (TopK, A)]), Self::CocoSketch => (Some(Disjoint), &[(ItemFrequency, A), (PartialKeySum, A)]), diff --git a/src/common/heap.rs b/src/common/heap.rs index 606a69db..4c0cfce4 100644 --- a/src/common/heap.rs +++ b/src/common/heap.rs @@ -172,6 +172,7 @@ impl TopKHeap { /// Provides access to the underlying data as a slice. /// Named `heap` for API compatibility with TopKHeap. pub fn heap(&self) -> &[HeapEntry] { + crate::analytics::ops::add(self.heap.len()); self.heap.as_slice() } @@ -203,6 +204,7 @@ impl TopKHeap { /// Clears the heap. pub fn clear(&mut self) { + crate::analytics::ops::add(2 * self.heap.len()); self.heap.clear(); self.slots.clear(); self.positions.clear(); @@ -264,7 +266,7 @@ impl TopKHeap { /// The arrays and the index reserve `min(heap_size, 1024)` and grow by /// doubling; the index follows std's `HashMap` layout, growing once more /// under churn when it is over half full. Saturates at `usize::MAX`. - pub fn estimated_memory_bytes(heap_size: usize, key: &DataInput) -> usize { + pub fn theoretical_memory_bytes(heap_size: usize, key: &DataInput) -> usize { let reserved = heap_size.min(PREALLOCATED_SLOTS); let entry_bytes = size_of::>(); let entry_slots = vec_slots_after_pushes(reserved, heap_size, entry_bytes); @@ -279,15 +281,16 @@ impl TopKHeap { // -- index maintenance --------------------------------------------------- fn lookup(&self, slot: u64, matches: impl Fn(&HeapEntry) -> bool) -> Option { - self.positions - .get(&slot)? - .iter() - .copied() - .find(|idx| matches(&self.heap[*idx])) + crate::analytics::ops::add(1); + self.positions.get(&slot)?.iter().copied().find(|idx| { + crate::analytics::ops::add(1); + matches(&self.heap[*idx]) + }) } /// Writes a new count onto a resident and re-sifts it. fn rescore(&mut self, idx: usize, count: C) { + crate::analytics::ops::add(1); self.heap[idx].count = count; let Self { heap, @@ -304,6 +307,7 @@ impl TopKHeap { let item = HeapEntry::new(key, count); if self.heap.len() < self.heap_size { let idx = self.heap.len(); + crate::analytics::ops::add(2); self.slots.push(slot); self.positions.entry(slot).or_default().push(idx); let Self { @@ -314,6 +318,7 @@ impl TopKHeap { } = self; heap.push_back_with(item, &mut |i, j| swap_entry(slots, positions, i, j)); } else { + crate::analytics::ops::add(3); let displaced = self.slots[0]; drop_entry(&mut self.positions, displaced, 0); self.slots[0] = slot; @@ -337,6 +342,7 @@ impl TopKHeap { if self.heap.len() < self.heap_size { return true; } + crate::analytics::ops::add(1); debug_assert!( !self.heap.is_empty(), "a full heap of capacity k > 0 has a root" @@ -363,17 +369,20 @@ impl TopKHeap { /// positions leaves that bucket unchanged and there is nothing to write. #[inline] fn swap_entry(slots: &mut [u64], positions: &mut Index, i: usize, j: usize) { + crate::analytics::ops::add(2); let (left, right) = (slots[i], slots[j]); if left == right { return; } retarget(positions, left, i, j); retarget(positions, right, j, i); + crate::analytics::ops::add(4); slots.swap(i, j); } #[inline] fn retarget(positions: &mut Index, digest: u64, from: usize, to: usize) { + crate::analytics::ops::add(1); if let Some(bucket) = positions.get_mut(&digest) && let Some(entry) = bucket.iter_mut().find(|entry| **entry == from) { @@ -383,11 +392,13 @@ fn retarget(positions: &mut Index, digest: u64, from: usize, to: usize) { #[inline] fn drop_entry(positions: &mut Index, digest: u64, idx: usize) { + crate::analytics::ops::add(1); if let Some(bucket) = positions.get_mut(&digest) { if let Some(at) = bucket.iter().position(|entry| *entry == idx) { bucket.swap_remove(at); } if bucket.is_empty() { + crate::analytics::ops::add(1); positions.remove(&digest); } } @@ -517,6 +528,16 @@ pub(crate) fn hash_index_bytes(reserved: usize, len: usize) -> usize { /// Buckets of the table [`hash_index_bytes`] describes; `None` past `usize`. fn hash_index_buckets(reserved: usize, len: usize, entry_bytes: usize) -> Option { + let mut buckets = hash_growth_buckets(reserved, len, entry_bytes)?; + if len >= HASH_GROUP_WIDTH && len > hash_capacity_of(buckets) / 2 { + buckets = hash_buckets_for(hash_capacity_of(buckets) + 1, entry_bytes)?; + } + Some(buckets) +} + +/// Buckets of a table created with capacity `reserved` after `len` distinct +/// inserts and no removals; `None` past `usize`. +fn hash_growth_buckets(reserved: usize, len: usize, entry_bytes: usize) -> Option { let mut buckets = hash_buckets_for(reserved, entry_bytes)?; while (buckets == 0 && len > 0) || (buckets > 0 && hash_capacity_of(buckets) < len) { let held = if buckets == 0 { @@ -526,12 +547,15 @@ fn hash_index_buckets(reserved: usize, len: usize, entry_bytes: usize) -> Option }; buckets = hash_buckets_for(held + 1, entry_bytes)?; } - if len >= HASH_GROUP_WIDTH && len > hash_capacity_of(buckets) / 2 { - buckets = hash_buckets_for(hash_capacity_of(buckets) + 1, entry_bytes)?; - } Some(buckets) } +/// Bytes std's `HashMap` of entries `T` holds after `len` distinct inserts +/// into an empty map and no removals. Saturates at `usize::MAX`. +pub(crate) fn hash_map_bytes_after_inserts(len: usize) -> usize { + hash_growth_buckets(0, len, size_of::()).map_or(usize::MAX, hash_table_bytes::) +} + /// Every element sits in heap order beneath the parent the array layout gives /// it, with the parent index computed here rather than taken from the heap. #[cfg(test)] diff --git a/src/common/input.rs b/src/common/input.rs index 52d1d6c5..f19b9cab 100644 --- a/src/common/input.rs +++ b/src/common/input.rs @@ -472,6 +472,19 @@ impl HydraCounter { } } + /// Bytes this cell holds: the enum and the wrapped sketch's heap, as its + /// `memory_bytes` counts them. + pub fn memory_bytes(&self) -> usize { + let (bytes, inline) = match self { + HydraCounter::CountMinSketch(s) => (s.memory_bytes(), size_of_val(s)), + HydraCounter::Hll(s) => (s.memory_bytes(), size_of_val(s)), + HydraCounter::CountSketch(s) => (s.memory_bytes(), size_of_val(s)), + HydraCounter::Kll(s) => (s.memory_bytes(), size_of_val(s)), + HydraCounter::UnivMon(s) => (s.memory_bytes(), size_of_val(s)), + }; + size_of::() + bytes - inline + } + /// Mass inserted into this cell: a Count-Min cell's inner row sum (exact while /// each counter stays below 2^31), a KLL cell's retained weight (approximate). /// Count Sketch, HyperLogLog and UnivMon cells return an error. @@ -482,6 +495,7 @@ impl HydraCounter { if storage.rows() == 0 { return Ok(0.0); } + crate::analytics::ops::add(storage.cols()); let sum: i64 = storage.row_slice(0).iter().map(|&c| i64::from(c)).sum(); Ok(sum as f64) } diff --git a/src/common/structure_utils.rs b/src/common/structure_utils.rs index 4c164a98..507556d9 100644 --- a/src/common/structure_utils.rs +++ b/src/common/structure_utils.rs @@ -385,6 +385,7 @@ impl Nitro { /// This is used by query_median_with_custom_hash for HydraCounter queries #[inline(always)] pub fn compute_median_inline_f64(values: &mut [f64]) -> f64 { + crate::analytics::ops::add_sort(values.len()); match values.len() { 0 => 0.0, 1 => values[0], diff --git a/src/common/structures/bit_matrix.rs b/src/common/structures/bit_matrix.rs index dd86cd60..bb8b5467 100644 --- a/src/common/structures/bit_matrix.rs +++ b/src/common/structures/bit_matrix.rs @@ -115,6 +115,7 @@ impl BitMatrix { (other.rows, other.cols), "bit matrices must have the same dimensions to be unioned" ); + crate::analytics::ops::add(2 * self.words.len()); for (dst, src) in self.words.iter_mut().zip(other.words.iter()) { *dst |= *src; } @@ -148,6 +149,12 @@ impl BitMatrix { /// If `row >= rows` or `col >= cols`. #[inline(always)] pub fn get(&self, row: usize, col: usize) -> bool { + crate::analytics::ops::add(1); + self.read(row, col) + } + + #[inline(always)] + fn read(&self, row: usize, col: usize) -> bool { self.check_bounds(row, col); let (idx, bit) = self.locate(row, col); self.words[idx] & bit != 0 @@ -159,6 +166,7 @@ impl BitMatrix { /// If `row >= rows` or `col >= cols`. #[inline(always)] pub fn set(&mut self, row: usize, col: usize) { + crate::analytics::ops::add(1); self.check_bounds(row, col); let (idx, bit) = self.locate(row, col); self.words[idx] |= bit; @@ -170,6 +178,12 @@ impl BitMatrix { /// If `row >= rows` or `col >= cols`. #[inline(always)] pub fn put(&mut self, row: usize, col: usize, value: bool) { + crate::analytics::ops::add(1); + self.write(row, col, value); + } + + #[inline(always)] + fn write(&mut self, row: usize, col: usize, value: bool) { self.check_bounds(row, col); let (idx, bit) = self.locate(row, col); if value { @@ -204,9 +218,10 @@ impl MatrixStorage for BitMatrix { where F: Fn(&mut Self::Counter, V), { - let mut cell = self.get(row, col); + crate::analytics::ops::add(1); + let mut cell = self.read(row, col); op(&mut cell, value); - self.put(row, col, cell); + self.write(row, col, cell); } #[inline(always)] @@ -223,11 +238,12 @@ impl MatrixStorage for BitMatrix { F: Fn(&mut Self::Counter, &V, usize), V: Clone, { + crate::analytics::ops::add(self.rows); for row in 0..self.rows { let col = self.col_for_row(hashed_val, row); - let mut cell = self.get(row, col); + let mut cell = self.read(row, col); op(&mut cell, &value, row); - self.put(row, col, cell); + self.write(row, col, cell); } } @@ -256,6 +272,8 @@ impl MatrixStorage for BitMatrix { Hash: MatrixFastHash, F: Fn(&Self::Counter, usize, &Hash) -> f64, { + crate::analytics::ops::add(self.rows); + crate::analytics::ops::add_sort(self.rows); let mut values: crate::common::structures::matrix_storage::RowEstimates = (0..self.rows) .map(|row| { let col = self.col_for_row(hashed_val, row); diff --git a/src/common/structures/fixed_structure.rs b/src/common/structures/fixed_structure.rs index c0a77156..3678d6dd 100644 --- a/src/common/structures/fixed_structure.rs +++ b/src/common/structures/fixed_structure.rs @@ -244,12 +244,14 @@ macro_rules! impl_fixed_matrix { where F: Fn(&mut Self::Counter, V), { + $crate::analytics::ops::add(1); let idx = row * $cols + col; op(&mut self.data[idx], value); } #[inline(always)] fn increment_by_row(&mut self, row: usize, col: usize, value: Self::Counter) { + $crate::analytics::ops::add(1); let idx = row * $cols + col; self.data[idx] += value; } @@ -261,6 +263,7 @@ macro_rules! impl_fixed_matrix { F: Fn(&mut Self::Counter, &V, usize), V: Clone, { + $crate::analytics::ops::add($rows); for row in 0..$rows { let col = hashed_val.col_for_row(row, $cols); let idx = row * $cols + col; @@ -275,6 +278,7 @@ macro_rules! impl_fixed_matrix { F: Fn(&Self::Counter, usize, &Hash) -> R, R: PartialOrd, { + $crate::analytics::ops::add($rows); let col = hashed_val.col_for_row(0, $cols); let mut min = op(&self.data[col], 0, hashed_val); for row in 1..$rows { @@ -294,6 +298,7 @@ macro_rules! impl_fixed_matrix { Hash: $crate::MatrixFastHash, F: Fn(&Self::Counter, usize, &Hash) -> f64, { + $crate::analytics::ops::add(2 * $rows); let mut estimates = [0.0_f64; $rows]; for (row, estimate) in estimates.iter_mut().enumerate() { let col = hashed_val.col_for_row(row, $cols); @@ -305,6 +310,7 @@ macro_rules! impl_fixed_matrix { #[inline(always)] fn query_one_counter(&self, row: usize, col: usize) -> Self::Counter { + $crate::analytics::ops::add(1); self.data[row * $cols + col] } } diff --git a/src/common/structures/heap.rs b/src/common/structures/heap.rs index ecd8c2f6..ac6a21a2 100644 --- a/src/common/structures/heap.rs +++ b/src/common/structures/heap.rs @@ -182,8 +182,11 @@ impl> CommonHeap { pub fn push(&mut self, value: T) { if self.data.len() < self.size { self.push_back_with(value, &mut |_, _| {}); - } else if !self.data.is_empty() && self.order.should_replace_root(&self.data[0], &value) { - self.replace_root_with(value, &mut |_, _| {}); + } else if !self.data.is_empty() { + crate::analytics::ops::add(1); + if self.order.should_replace_root(&self.data[0], &value) { + self.replace_root_with(value, &mut |_, _| {}); + } } } @@ -196,6 +199,7 @@ impl> CommonHeap { /// The caller is responsible for the capacity check; this appends /// unconditionally. pub fn push_back_with(&mut self, value: T, on_swap: &mut impl FnMut(usize, usize)) { + crate::analytics::ops::add(1); self.data.push(value); self.bubble_up_with(self.data.len() - 1, on_swap); } @@ -206,6 +210,7 @@ impl> CommonHeap { /// /// Panics if the heap is empty. pub fn replace_root_with(&mut self, value: T, on_swap: &mut impl FnMut(usize, usize)) -> T { + crate::analytics::ops::add(1); let displaced = std::mem::replace(&mut self.data[0], value); self.bubble_down_with(0, on_swap); displaced @@ -217,8 +222,10 @@ impl> CommonHeap { return None; } if self.data.len() == 1 { + crate::analytics::ops::add(1); return self.data.pop(); } + crate::analytics::ops::add(3); let root = self.data.swap_remove(0); self.bubble_down(0); Some(root) @@ -297,6 +304,7 @@ impl> CommonHeap { let left = Self::left_child(idx); let right = Self::right_child(idx); let mut target = idx; + crate::analytics::ops::add((left < len) as usize + (right < len) as usize); // Find which child (if any) should be swapped with parent if left < len && self.order.should_swap(&self.data[target], &self.data[left]) { @@ -314,6 +322,7 @@ impl> CommonHeap { break; } + crate::analytics::ops::add(4); self.data.swap(idx, target); on_swap(idx, target); idx = target; @@ -326,10 +335,12 @@ impl> CommonHeap { fn bubble_up_with(&mut self, mut idx: usize, on_swap: &mut impl FnMut(usize, usize)) { while idx > 0 { let parent_idx = Self::parent(idx); + crate::analytics::ops::add(1); if self .order .should_swap(&self.data[parent_idx], &self.data[idx]) { + crate::analytics::ops::add(4); self.data.swap(parent_idx, idx); on_swap(parent_idx, idx); idx = parent_idx; diff --git a/src/common/structures/vector2d.rs b/src/common/structures/vector2d.rs index d36370e1..f389657c 100644 --- a/src/common/structures/vector2d.rs +++ b/src/common/structures/vector2d.rs @@ -203,6 +203,7 @@ impl Vector2D { F: Fn(&mut T, V), T: Clone, { + crate::analytics::ops::add(1); let idx = row * self.cols + col; op(&mut self.data[idx], value); } @@ -263,6 +264,7 @@ impl Vector2D { F: Fn(&mut T, &V, usize), V: Clone, { + crate::analytics::ops::add(self.rows); for row in 0..self.rows { let col = self.col_for_row(hashed_val, row); let idx = row * self.cols + col; @@ -306,6 +308,7 @@ impl Vector2D { where T: Clone, { + crate::analytics::ops::add(1); self.data[row * self.cols + col].clone() } @@ -344,6 +347,7 @@ impl Vector2D { F: Fn(&T, usize, &Hash) -> R, R: PartialOrd, { + crate::analytics::ops::add(self.rows); let c0 = self.col_for_row(hashed_val, 0); let mut min = op(&self.data[c0], 0, hashed_val); for row in 1..self.rows { @@ -395,6 +399,7 @@ impl Vector2D { Hash: MatrixFastHash, F: Fn(&T, usize, &Hash) -> f64, { + crate::analytics::ops::add(2 * self.rows); let mut estimates = RowEstimates::with_capacity(self.rows); for row in 0..self.rows { let col = self.col_for_row(hashed_val, row); @@ -440,6 +445,7 @@ impl Vector2D { F: Fn(&T, usize, &MatrixHash) -> R, R: PartialOrd, { + crate::analytics::ops::add(self.rows); let c0 = self.col_for_row(hashed_val, 0); let mut max = op(&self.data[c0], 0, hashed_val); for row in 1..self.rows { @@ -482,6 +488,7 @@ impl Vector2D { F: Fn(&T, &Q, usize, &MatrixHash) -> R, R: PartialOrd, { + crate::analytics::ops::add(self.rows); let c0 = self.col_for_row(hashed_val, 0); let mut min = op(&self.data[c0], query_key, 0, hashed_val); for row in 1..self.rows { @@ -524,6 +531,7 @@ impl Vector2D { F: Fn(&T, &Q, usize, &MatrixHash) -> R, R: PartialOrd, { + crate::analytics::ops::add(self.rows); let c0 = self.col_for_row(hashed_val, 0); let mut max = op(&self.data[c0], query_key, 0, hashed_val); for row in 1..self.rows { @@ -567,6 +575,7 @@ impl Vector2D { where F: Fn(&T, &Q, usize, &MatrixHash) -> f64, { + crate::analytics::ops::add(2 * self.rows); let mut estimates = RowEstimates::with_capacity(self.rows); for row in 0..self.rows { let col = self.col_for_row(hashed_val, row); @@ -626,6 +635,7 @@ impl Vector2D { where F: Fn(R, &T, &Q, usize, &MatrixHash) -> R, { + crate::analytics::ops::add(self.rows); let mut acc = init; for row in 0..self.rows { let col = self.col_for_row(hashed_val, row); @@ -683,6 +693,7 @@ where #[inline(always)] fn increment_by_row(&mut self, row: usize, col: usize, value: Self::Counter) { + crate::analytics::ops::add(1); let idx = row * self.cols + col; self.data[idx] += value; } diff --git a/src/lib.rs b/src/lib.rs index fe46023c..c662c91a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,8 @@ //! submodules directly when you need lower-level storage, hashing, or //! framework-specific APIs. +/// Theoretical error, memory and op counts per sketch. +pub mod analytics; pub(crate) mod asapv1; pub mod capability; /// Shared primitives used across sketches, including input wrappers, hashers, @@ -26,6 +28,7 @@ pub mod sketches; #[cfg(test)] pub mod test_utils; +pub use analytics::{ErrorBound, ErrorMetric, NormScale, SketchAnalytics}; pub use asapv1::envelope::peek_kind_id; pub use capability::{ FamilyCapability, MergeInputRule, MergedError, SketchAlgorithm, StatisticKind, diff --git a/src/sketch_framework/eh.rs b/src/sketch_framework/eh.rs index a1210c83..9efa6f7b 100644 --- a/src/sketch_framework/eh.rs +++ b/src/sketch_framework/eh.rs @@ -60,6 +60,7 @@ impl EhBucket { /// cached mass are left as they were. pub fn merge(&mut self, other: EhBucket) -> Result<(), String> { self.bucket.merge(&other.bucket)?; + crate::analytics::ops::add(1); self.size += other.size; self.max_time = self.max_time.max(other.max_time); self.min_time = self.min_time.min(other.min_time); @@ -103,11 +104,15 @@ impl ExponentialHistogram { .iter() .take_while(|b| b.max_time < time.saturating_sub(self.window)) .count(); + crate::analytics::ops::add((expired_count + 1).min(self.payload.len())); if expired_count > 0 { + crate::analytics::ops::add(2 * (self.payload.len() - expired_count)); self.payload.drain(0..expired_count); } + #[cfg(feature = "op-count")] + crate::analytics::ops::add(self.prototype.cells()); let mut sketch = self.prototype.clone(); update_fn(&mut sketch); let new_eh_vol = EhBucket { @@ -117,6 +122,7 @@ impl ExponentialHistogram { min_time: time, max_time: time, }; + crate::analytics::ops::add(1); self.payload.push(new_eh_vol); // The public signature cannot carry the error out. A refused merge @@ -142,6 +148,7 @@ impl ExponentialHistogram { let mut i = s_count - 2; loop { + crate::analytics::ops::add(2); if self.payload[i].size == self.payload[i + 1].size { same_size_vol += 1; } else { @@ -149,6 +156,9 @@ impl ExponentialHistogram { self.merge_at_index(i + 1)?; } same_size_vol = 1; + if i + 1 < self.payload.len() && i > 0 { + crate::analytics::ops::add(2); + } if i + 1 < self.payload.len() && i > 0 && self.payload[i + 1].size == self.payload[i].size @@ -184,11 +194,13 @@ impl ExponentialHistogram { let mut sum_l22_newer = 0.0; for i in (0..(self.payload.len() - 1)).rev() { + crate::analytics::ops::add(2); let pair_l22 = self.payload[i].l2_mass + self.payload[i + 1].l2_mass; let threshold = sum_l22_newer / (self.k as f64); if pair_l22 <= threshold + MASS_EPSILON { return Some(i); } + crate::analytics::ops::add(1); sum_l22_newer += self.payload[i + 1].l2_mass; } @@ -202,8 +214,11 @@ impl ExponentialHistogram { if index + 1 >= self.payload.len() { return Ok(()); } + #[cfg(feature = "op-count")] + crate::analytics::ops::add(1 + self.payload[index + 1].bucket.cells()); let vol_to_merge = self.payload[index + 1].clone(); self.payload[index].merge(vol_to_merge)?; + crate::analytics::ops::add(2 * (self.payload.len() - index - 2)); self.payload.remove(index + 1); Ok(()) } @@ -246,6 +261,7 @@ impl ExponentialHistogram { let mut from_volume = 0; let mut to_volume = 0; + crate::analytics::ops::add(self.payload.len() + 4); for (i, vol) in self.payload.iter().enumerate() { if t1 >= vol.min_time && t1 <= vol.max_time { from_volume = i; @@ -269,9 +285,12 @@ impl ExponentialHistogram { if to_volume >= self.payload.len() { to_volume = self.payload.len() - 1; } + #[cfg(feature = "op-count")] + crate::analytics::ops::add(self.payload[from_volume].bucket.cells()); if from_volume < to_volume { let mut merged = self.payload[from_volume].bucket.clone(); for i in (from_volume + 1)..=to_volume { + crate::analytics::ops::add(1); merged.merge(&self.payload[i].bucket).ok()?; } Some(merged) @@ -293,6 +312,17 @@ impl ExponentialHistogram { } } + /// Bytes this histogram holds: the struct, its bucket slots and each + /// bucket's and the prototype's sketch heap + /// ([`EhBucketSketch::memory_bytes`]). + pub fn memory_bytes(&self) -> usize { + let heap = |sketch: &EhBucketSketch| sketch.memory_bytes() - size_of::(); + size_of::() + + self.payload.capacity() * size_of::() + + self.payload.iter().map(|b| heap(&b.bucket)).sum::() + + heap(&self.prototype) + } + /// Get memory statistics pub fn memory_info(&self) -> (usize, Vec) { let count = self.payload.len(); diff --git a/src/sketch_framework/eh_bucket_sketch.rs b/src/sketch_framework/eh_bucket_sketch.rs index d28b8ba3..0ba2c45e 100644 --- a/src/sketch_framework/eh_bucket_sketch.rs +++ b/src/sketch_framework/eh_bucket_sketch.rs @@ -63,6 +63,67 @@ impl EhBucketSketch { } } + /// Bytes this sketch holds: the enum and the wrapped sketch's heap, as + /// its `memory_bytes` counts them. + pub fn memory_bytes(&self) -> usize { + let (bytes, inline) = match self { + EhBucketSketch::CountMinSketch(s) => (s.memory_bytes(), size_of_val(s)), + EhBucketSketch::CocoSketch(s) => (s.memory_bytes(), size_of_val(s)), + EhBucketSketch::CountSketchWithL2Norm(s) => (s.memory_bytes(), size_of_val(s)), + EhBucketSketch::CountSketch(s) => (s.memory_bytes(), size_of_val(s)), + EhBucketSketch::DdSketch(s) => (s.memory_bytes(), size_of_val(s)), + EhBucketSketch::ElasticSketch(s) => (s.memory_bytes(), size_of_val(s)), + EhBucketSketch::Hll(s) => (s.memory_bytes(), size_of_val(s)), + EhBucketSketch::Kll(s) => (s.memory_bytes(), size_of_val(s)), + #[cfg(feature = "experimental")] + EhBucketSketch::Uniform(s) => (s.memory_bytes(), size_of_val(s)), + EhBucketSketch::UnivMon(s) => (s.memory_bytes(), size_of_val(s)), + }; + size_of::() + bytes - inline + } + + /// `C_in`: the cells a clone of this sketch copies, its heap bytes over + /// its cell bytes. + #[cfg(feature = "op-count")] + pub(crate) fn cells(&self) -> usize { + use crate::analytics::frameworks::{InnerSketch, heap_cells}; + let heap = self.memory_bytes() - size_of::(); + let cell = match self { + EhBucketSketch::CountMinSketch(_) => { + , FastPath> as InnerSketch>::cell_bytes( + &Default::default(), + ) + } + EhBucketSketch::CocoSketch(_) => { + ::cell_bytes(&Default::default()) + } + EhBucketSketch::CountSketchWithL2Norm(_) => { + ::cell_bytes(&Default::default()) + } + EhBucketSketch::CountSketch(_) => { + , FastPath> as InnerSketch>::cell_bytes( + &Default::default(), + ) + } + EhBucketSketch::DdSketch(_) => { + ::cell_bytes(&Default::default()) + } + EhBucketSketch::ElasticSketch(_) => { + ::cell_bytes(&Default::default()) + } + EhBucketSketch::Hll(_) => { + as InnerSketch>::cell_bytes(&Default::default()) + } + EhBucketSketch::Kll(_) => ::cell_bytes(&Default::default()), + #[cfg(feature = "experimental")] + EhBucketSketch::Uniform(_) => { + ::cell_bytes(&Default::default()) + } + EhBucketSketch::UnivMon(_) => ::cell_bytes(&Default::default()), + }; + heap_cells(heap, 0, cell) + } + pub(crate) fn eh_l2_mass(&self) -> Option { match self { EhBucketSketch::CountSketchWithL2Norm(sketch) => Some(sketch.l2_squared()), diff --git a/src/sketch_framework/eh_univmon.rs b/src/sketch_framework/eh_univmon.rs index 925b0054..a3a1f9b1 100644 --- a/src/sketch_framework/eh_univmon.rs +++ b/src/sketch_framework/eh_univmon.rs @@ -17,7 +17,16 @@ const MASS_EPSILON: f64 = 1e-9; const DEFAULT_ROWS: usize = 5; const DEFAULT_COLS: usize = 2048; const DEFAULT_LAYERS: usize = 8; -const DEFAULT_POOL_INITIAL_SIZE: usize = 4; +pub(crate) const DEFAULT_POOL_INITIAL_SIZE: usize = 4; + +/// Heap bytes a key owns. +fn owned_key_bytes(key: &OwnedDataInput) -> usize { + match key { + OwnedDataInput::String(s) => s.capacity(), + OwnedDataInput::Bytes(b) => b.capacity(), + _ => 0, + } +} fn exact_l2_squared(counts: &HashMap) -> f64 { counts.values().map(|&v| (v as f64) * (v as f64)).sum() @@ -183,7 +192,9 @@ impl ExponentialHistogramUnivMon { .iter() .take_while(|b| b.max_time < cutoff) .count(); + crate::analytics::ops::add((expired_count + 1).min(self.sketch_buckets.len())); if expired_count > 0 { + crate::analytics::ops::add(2 * (self.sketch_buckets.len() - expired_count)); let expired: Vec = self.sketch_buckets.drain(0..expired_count).collect(); for bucket in expired { @@ -197,7 +208,9 @@ impl ExponentialHistogramUnivMon { .iter() .take_while(|b| b.max_time < cutoff) .count(); + crate::analytics::ops::add((expired + 1).min(self.exact_buckets.len())); if expired > 0 { + crate::analytics::ops::add(2 * (self.exact_buckets.len() - expired)); self.exact_buckets.drain(0..expired); } @@ -205,6 +218,7 @@ impl ExponentialHistogramUnivMon { let owned_key = key.to_owned_input(); let mut counts = HashMap::new(); counts.insert(owned_key, weight); + crate::analytics::ops::add(2); self.exact_buckets.push(EhUnivMonExactBucket { counts, l2_squared: (weight as f64) * (weight as f64), @@ -218,12 +232,15 @@ impl ExponentialHistogramUnivMon { if self.exact_buckets.len() >= 2 { let mut i = self.exact_buckets.len() - 2; loop { + crate::analytics::ops::add(2); let pair_l22 = self.exact_buckets[i].l2_squared + self.exact_buckets[i + 1].l2_squared; let threshold = sum_l22 / (self.k as f64); if pair_l22 <= threshold + MASS_EPSILON { // Merge i+1 into i + crate::analytics::ops::add(1 + 2 * (self.exact_buckets.len() - i - 2)); let other = self.exact_buckets.remove(i + 1); + crate::analytics::ops::add(other.counts.len()); let bucket = &mut self.exact_buckets[i]; bucket.total_weight += other.total_weight; bucket.max_time = bucket.max_time.max(other.max_time); @@ -231,8 +248,10 @@ impl ExponentialHistogramUnivMon { for (k, v) in other.counts { *bucket.counts.entry(k).or_insert(0) += v; } + crate::analytics::ops::add(bucket.counts.len()); bucket.l2_squared = exact_l2_squared(&bucket.counts); } else { + crate::analytics::ops::add(1); sum_l22 += self.exact_buckets[i + 1].l2_squared; } if i == 0 { @@ -251,11 +270,13 @@ impl ExponentialHistogramUnivMon { } fn promote_oldest_exact_bucket(&mut self, sum_l22: f64) { + crate::analytics::ops::add(1 + 2 * (self.exact_buckets.len() - 1)); let oldest = self.exact_buckets.remove(0); // Take a sketch from the pool (moved, not borrowed) — zero allocation // if the pool has a recycled sketch available. let mut sketch = self.pool.take(); + crate::analytics::ops::add(oldest.counts.len()); for (key, value) in &oldest.counts { let input = key.as_data_input(); sketch.update_weighted(&input, *value); @@ -263,6 +284,7 @@ impl ExponentialHistogramUnivMon { let l2_squared = sketch.layer_sketches[0].l2().powi(2); + crate::analytics::ops::add(1); self.sketch_buckets.push(EhUnivMonSketchBucket { sketch, l2_squared, @@ -287,6 +309,7 @@ impl ExponentialHistogramUnivMon { let pair_l22 = l22_i + l22_next; let threshold = sum_l22 / (self.k as f64); if pair_l22 <= threshold + MASS_EPSILON { + crate::analytics::ops::add(1 + 2 * (self.sketch_buckets.len() - i - 2)); let other = self.sketch_buckets.remove(i + 1); let bucket = &mut self.sketch_buckets[i]; bucket.sketch.merge(&other.sketch); @@ -316,6 +339,7 @@ impl ExponentialHistogramUnivMon { let mut from_bucket: usize = 0; let mut to_bucket: usize = 0; + crate::analytics::ops::add(2 * total + 6); // Search sketch buckets for i in 0..s_count { @@ -383,8 +407,11 @@ impl ExponentialHistogramUnivMon { // Three cases if to_bucket < s_count { // Case 1: Both in sketch tier + #[cfg(feature = "op-count")] + crate::analytics::ops::add(self.sketch_buckets[from_bucket].sketch.cells()); let mut merged = self.sketch_buckets[from_bucket].sketch.clone(); for i in (from_bucket + 1)..=to_bucket { + crate::analytics::ops::add(1); merged.merge(&self.sketch_buckets[i].sketch); } Some(EhUnivMonQueryResult::Sketch(merged)) @@ -394,10 +421,12 @@ impl ExponentialHistogramUnivMon { let to_map = to_bucket - s_count; let mut merged_map: HashMap = HashMap::new(); for i in from_map..=to_map { + crate::analytics::ops::add(1 + 2 * self.exact_buckets[i].counts.len()); for (k, &v) in &self.exact_buckets[i].counts { *merged_map.entry(k.clone()).or_insert(0) += v; } } + crate::analytics::ops::add(merged_map.len()); let total_weight = merged_map.values().sum::() as usize; Some(EhUnivMonQueryResult::Exact { counts: merged_map, @@ -406,7 +435,10 @@ impl ExponentialHistogramUnivMon { } else { // Case 3: Hybrid — from in sketch, to in map let mut merged = UnivMon::new(self.heap_size, self.rows, self.cols, self.layers); + #[cfg(feature = "op-count")] + crate::analytics::ops::add(merged.cells()); for i in from_bucket..s_count { + crate::analytics::ops::add(1); merged.merge(&self.sketch_buckets[i].sketch); } @@ -414,12 +446,14 @@ impl ExponentialHistogramUnivMon { let to_map = to_bucket - s_count; let mut map_merged: HashMap = HashMap::new(); for i in 0..=to_map { + crate::analytics::ops::add(1 + 2 * self.exact_buckets[i].counts.len()); for (k, &v) in &self.exact_buckets[i].counts { *map_merged.entry(k.clone()).or_insert(0) += v; } } // Insert map entries into merged sketch + crate::analytics::ops::add(map_merged.len()); for (key, value) in &map_merged { let input = key.as_data_input(); merged.update_weighted(&input, *value); @@ -503,6 +537,27 @@ impl ExponentialHistogramUnivMon { } } + /// Bytes this histogram holds: the struct, both bucket lists' slots, each + /// UnivMon bucket's heap, each exact bucket's map and key bytes, and the + /// pool ([`UnivMonPool::memory_bytes`]). + pub fn memory_bytes(&self) -> usize { + let univmon = |s: &UnivMon| s.memory_bytes() - size_of::(); + let exact = |b: &EhUnivMonExactBucket| { + crate::common::heap::hash_map_bytes::<(OwnedDataInput, i64)>(b.counts.capacity()) + + b.counts.keys().map(owned_key_bytes).sum::() + }; + size_of::() - size_of::() + + self.sketch_buckets.capacity() * size_of::() + + self + .sketch_buckets + .iter() + .map(|b| univmon(&b.sketch)) + .sum::() + + self.exact_buckets.capacity() * size_of::() + + self.exact_buckets.iter().map(exact).sum::() + + self.pool.memory_bytes() + } + pub fn memory_info(&self) -> (usize, usize, Vec, Vec) { let sketch_sizes: Vec = self.sketch_buckets.iter().map(|b| b.total_weight).collect(); let map_sizes: Vec = self.exact_buckets.iter().map(|b| b.total_weight).collect(); diff --git a/src/sketch_framework/hashlayer.rs b/src/sketch_framework/hashlayer.rs index 8972db15..cd158a37 100644 --- a/src/sketch_framework/hashlayer.rs +++ b/src/sketch_framework/hashlayer.rs @@ -113,6 +113,19 @@ impl EnsembleSketch { } } + /// Bytes this sketch holds: the enum, and a boxed sketch's whole + /// allocation or an inline sketch's heap. + pub fn memory_bytes(&self) -> usize { + size_of::() + + match self { + EnsembleSketch::CountMinFast(sketch) => sketch.memory_bytes(), + EnsembleSketch::CountSketchFast(sketch) => sketch.memory_bytes(), + EnsembleSketch::HllErtl(hll) => hll.memory_bytes() - size_of_val(hll), + EnsembleSketch::HllClassic(hll) => hll.memory_bytes() - size_of_val(hll), + EnsembleSketch::HllHip(hll) => hll.memory_bytes() - size_of_val(hll), + } + } + /// Returns the frequency estimate for a key. /// `Some(f64)` for CMS / Count, `None` for HLL. pub fn estimate_with_hash(&self, hash: &MatrixHash) -> Option { @@ -259,6 +272,7 @@ where /// Hash `val` once and insert into every sketch in the layer. pub fn update(&mut self, val: &DataInput) { let hash = self.hash_input(val); + crate::analytics::ops::add(self.sketches.len()); for i in 0..self.sketches.len() { self.sketches[i].update_with_hash(&hash); } @@ -266,6 +280,7 @@ where /// Insert a pre-computed hash into every sketch in the layer. pub fn update_with_hash(&mut self, hash: &H::HashType) { + crate::analytics::ops::add(self.sketches.len()); for i in 0..self.sketches.len() { self.sketches[i].update_with_hash(hash); } @@ -276,6 +291,7 @@ where let hash = self.hash_input(val); for &idx in indices { if idx < self.sketches.len() { + crate::analytics::ops::add(1); self.sketches[idx].update_with_hash(&hash); } } @@ -285,6 +301,7 @@ where pub fn update_at_with_hash(&mut self, indices: &[usize], hash: &H::HashType) { for &idx in indices { if idx < self.sketches.len() { + crate::analytics::ops::add(1); self.sketches[idx].update_with_hash(hash); } } @@ -329,6 +346,7 @@ where return Err("index out of bounds"); } let hash = self.hash_input(val); + crate::analytics::ops::add(1); self.sketches[index] .estimate_with_hash(&hash) .ok_or("sketch at this index is not a frequency sketch") @@ -343,6 +361,7 @@ where if index >= self.sketches.len() { return Err("index out of bounds"); } + crate::analytics::ops::add(1); self.sketches[index] .estimate_with_hash(hash) .ok_or("sketch at this index is not a frequency sketch") @@ -358,6 +377,7 @@ where if index >= self.sketches.len() { return Err("index out of bounds"); } + crate::analytics::ops::add(1); self.sketches[index] .cardinality() .ok_or("sketch at this index is not a cardinality sketch") @@ -365,6 +385,18 @@ where // -- Accessors ------------------------------------------------------------ + /// Bytes this ensemble holds: the struct, its sketch slots and each + /// sketch's own bytes ([`EnsembleSketch::memory_bytes`]). + pub fn memory_bytes(&self) -> usize { + size_of::() + + self.sketches.capacity() * size_of::() + + self + .sketches + .iter() + .map(|s| s.memory_bytes() - size_of::()) + .sum::() + } + /// Returns the number of sketches in the ensemble. pub fn len(&self) -> usize { self.sketches.len() diff --git a/src/sketch_framework/hydra.rs b/src/sketch_framework/hydra.rs index 7c5e1073..7e97300e 100644 --- a/src/sketch_framework/hydra.rs +++ b/src/sketch_framework/hydra.rs @@ -89,6 +89,7 @@ impl TryFrom> for KeySchema { .map(|label| { let mut escaped = String::with_capacity(label.len()); push_escaped(&mut escaped, label); + escaped.shrink_to_fit(); escaped }) .collect(); @@ -285,6 +286,7 @@ impl Hydra { if self_cells.len() != other_cells.len() { return Err("Hydra storage length mismatch while merging".to_string()); } + crate::analytics::ops::add(2 * self_cells.len()); for (self_counter, other_counter) in self_cells.iter_mut().zip(other_cells.iter()) { self_counter.merge(other_counter)?; } @@ -340,6 +342,23 @@ impl Hydra { ) } + /// Bytes this Hydra holds: the struct, its cells + /// ([`HydraCounter::memory_bytes`]), the prototype's heap and the schema's + /// labels. + pub fn memory_bytes(&self) -> usize { + let heap = |cell: &HydraCounter| cell.memory_bytes() - size_of::(); + let labels = |labels: &Vec| { + labels.capacity() * size_of::() + + labels.iter().map(String::capacity).sum::() + }; + size_of::() + + size_of_val(self.cells.as_slice()) + + self.cells.as_slice().iter().map(heap).sum::() + + heap(&self.prototype) + + labels(&self.schema.labels) + + labels(&self.schema.escaped_labels) + } + /// Convenience method for querying frequency (for CountMinSketch-based Hydra) /// This is a wrapper around query_key with HydraQuery::Frequency pub fn query_frequency(&self, key: &[Option<&str>], value: &DataInput) -> Result { diff --git a/src/sketch_framework/nitro.rs b/src/sketch_framework/nitro.rs index 2e6c51d5..7bc1c407 100644 --- a/src/sketch_framework/nitro.rs +++ b/src/sketch_framework/nitro.rs @@ -426,6 +426,20 @@ impl NitroBatch { } } +impl NitroBatch, FastPath, H>> { + /// Bytes this wrapper holds: the struct and the target's heap. + pub fn memory_bytes(&self) -> usize { + size_of::() + self.sk.memory_bytes() - size_of_val(&self.sk) + } +} + +impl NitroBatch, FastPath, H>> { + /// Bytes this wrapper holds: the struct and the target's heap. + pub fn memory_bytes(&self) -> usize { + size_of::() + self.sk.memory_bytes() - size_of_val(&self.sk) + } +} + impl NitroBatch { /// Returns the wrapped sketch's estimate for `value`. pub fn estimate_median(&self, value: &DataInput) -> f64 { diff --git a/src/sketch_framework/octo.rs b/src/sketch_framework/octo.rs index 90f52cd6..9e785045 100644 --- a/src/sketch_framework/octo.rs +++ b/src/sketch_framework/octo.rs @@ -927,6 +927,15 @@ enum WorkerMsg { End, } +#[cfg(feature = "octo-runtime")] +/// Bytes one input-queue slot and one delta-queue slot of plan `L` hold, +/// with 16 bytes of channel bookkeeping. +pub(crate) fn queue_slot_bytes() -> usize { + size_of::::Payload>>() + + size_of::<::Delta>() + + 16 +} + #[cfg(feature = "octo-runtime")] enum AggregatorMsg { /// Apply everything already queued, then acknowledge. diff --git a/src/sketch_framework/sketch_catalog.rs b/src/sketch_framework/sketch_catalog.rs index 367970eb..33bfcdc9 100644 --- a/src/sketch_framework/sketch_catalog.rs +++ b/src/sketch_framework/sketch_catalog.rs @@ -32,6 +32,8 @@ pub trait CountMinFastOps { fn update_with_hash(&mut self, hash: &MatrixHash); /// Returns the estimate for one precomputed hash. fn estimate_with_hash(&self, hash: &MatrixHash) -> f64; + /// Bytes the sketch holds. + fn memory_bytes(&self) -> usize; } /// Type-erased adapter for regular-path Count Sketches. @@ -56,6 +58,8 @@ pub trait CountSketchFastOps { fn update_with_hash(&mut self, hash: &MatrixHash); /// Returns the estimate for one precomputed hash. fn estimate_with_hash(&self, hash: &MatrixHash) -> f64; + /// Bytes the sketch holds. + fn memory_bytes(&self) -> usize; } impl CountMinRegularOps for CountMinSketch @@ -101,6 +105,10 @@ where fn estimate_with_hash(&self, hash: &MatrixHash) -> f64 { self.estimate_with_hash(hash).to_f64() } + + fn memory_bytes(&self) -> usize { + self.memory_bytes() + } } impl CountSketchRegularOps for CountSketch @@ -146,4 +154,8 @@ where fn estimate_with_hash(&self, hash: &MatrixHash) -> f64 { self.estimate_with_hash(hash) } + + fn memory_bytes(&self) -> usize { + self.memory_bytes() + } } diff --git a/src/sketch_framework/tumbling.rs b/src/sketch_framework/tumbling.rs index 3f8deb75..c9283aa4 100644 --- a/src/sketch_framework/tumbling.rs +++ b/src/sketch_framework/tumbling.rs @@ -41,6 +41,31 @@ pub trait TumblingWindowSketch: Clone + Sized { /// Reset to the empty state, preserving allocations where possible. fn tumbling_clear(&mut self); + + /// Bytes this sketch holds, for [`TumblingWindow::memory_bytes`]; + /// `size_of::()` unless overridden. + fn tumbling_memory_bytes(&self) -> usize { + size_of::() + } + + /// Cells a clone or clear of this sketch touches, which the `op-count` + /// feature counts; 0 unless overridden. + fn tumbling_cells(&self) -> usize { + 0 + } +} + +/// `C_in` of an in-crate tumbling sketch: heap bytes over cell bytes. +fn inner_cells( + memory_bytes: usize, + params: &S::Params, +) -> usize { + crate::analytics::frameworks::heap_cells(memory_bytes, size_of::(), S::cell_bytes(params)) +} + +/// Bytes one closed-window slot holds. +pub(crate) fn closed_window_bytes() -> usize { + size_of::>() } // --------------------------------------------------------------------------- @@ -116,6 +141,14 @@ impl TumblingWindowSketch for CountMinSketchFold { fn tumbling_clear(&mut self) { self.clear(); } + + fn tumbling_memory_bytes(&self) -> usize { + self.memory_bytes() + } + + fn tumbling_cells(&self) -> usize { + inner_cells::(self.memory_bytes(), &Default::default()) + } } impl TumblingWindowSketch for CountSketchFold { @@ -141,6 +174,14 @@ impl TumblingWindowSketch for CountSketchFold { fn tumbling_clear(&mut self) { self.clear(); } + + fn tumbling_memory_bytes(&self) -> usize { + self.memory_bytes() + } + + fn tumbling_cells(&self) -> usize { + inner_cells::(self.memory_bytes(), &Default::default()) + } } impl TumblingWindowSketch for Kll { @@ -165,6 +206,14 @@ impl TumblingWindowSketch for Kll { fn tumbling_clear(&mut self) { self.clear(); } + + fn tumbling_memory_bytes(&self) -> usize { + self.memory_bytes() + } + + fn tumbling_cells(&self) -> usize { + inner_cells::(self.memory_bytes(), &Default::default()) + } } impl TumblingWindowSketch for UnivMonQ { @@ -186,6 +235,14 @@ impl TumblingWindowSketch for UnivMonQ { fn tumbling_clear(&mut self) { self.clear(); } + + fn tumbling_memory_bytes(&self) -> usize { + self.memory_bytes() + } + + fn tumbling_cells(&self) -> usize { + inner_cells::(self.memory_bytes(), &self.config()) + } } // --------------------------------------------------------------------------- @@ -215,6 +272,7 @@ impl SketchPool { /// Take a sketch from the free-list, or allocate a fresh one. pub fn take(&mut self) -> S { + crate::analytics::ops::add(1); if let Some(s) = self.free_list.pop() { s } else { @@ -225,6 +283,8 @@ impl SketchPool { /// Return a sketch to the pool after clearing it. pub fn put(&mut self, mut sketch: S) { + #[cfg(feature = "op-count")] + crate::analytics::ops::add(1 + sketch.tumbling_cells()); sketch.tumbling_clear(); self.free_list.push(sketch); } @@ -329,6 +389,7 @@ impl TumblingWindow { /// Close the active window, push it to closed, evict if over limit. fn close_active(&mut self) { let old_active = std::mem::replace(&mut self.active, self.pool.take()); + crate::analytics::ops::add(1); self.closed.push(ClosedWindow { sketch: old_active, _window_id: self.active_window_id, @@ -338,6 +399,7 @@ impl TumblingWindow { // Evict oldest if over limit. while self.closed.len() > self.max_windows { + crate::analytics::ops::add(1 + 2 * (self.closed.len() - 1)); let evicted = self.closed.remove(0); self.pool.put(evicted.sketch); } @@ -345,8 +407,11 @@ impl TumblingWindow { /// Merge all closed windows + the active window into a single sketch. pub fn query_all(&self) -> S { + #[cfg(feature = "op-count")] + crate::analytics::ops::add(self.active.tumbling_cells()); let mut merged = self.active.clone(); for cw in &self.closed { + crate::analytics::ops::add(1); merged.tumbling_merge(&cw.sketch); } merged @@ -356,14 +421,30 @@ impl TumblingWindow { /// /// If `n >= closed_count()`, this is equivalent to `query_all()`. pub fn query_recent(&self, n: usize) -> S { + #[cfg(feature = "op-count")] + crate::analytics::ops::add(self.active.tumbling_cells()); let mut merged = self.active.clone(); let start = self.closed.len().saturating_sub(n); for cw in &self.closed[start..] { + crate::analytics::ops::add(1); merged.tumbling_merge(&cw.sketch); } merged } + /// Bytes this manager holds: the struct, its closed-window and pool + /// slots, and every sketch's heap as + /// [`TumblingWindowSketch::tumbling_memory_bytes`] counts it. + pub fn memory_bytes(&self) -> usize { + let heap = |s: &S| s.tumbling_memory_bytes() - size_of::(); + size_of::() + + self.closed.capacity() * size_of::>() + + self.pool.free_list.capacity() * size_of::() + + heap(&self.active) + + self.closed.iter().map(|w| heap(&w.sketch)).sum::() + + self.pool.free_list.iter().map(heap).sum::() + } + /// Reference to the active (currently open) sketch. pub fn active_sketch(&self) -> &S { &self.active diff --git a/src/sketch_framework/univmon.rs b/src/sketch_framework/univmon.rs index c29317a3..b9d3aed4 100644 --- a/src/sketch_framework/univmon.rs +++ b/src/sketch_framework/univmon.rs @@ -35,9 +35,9 @@ use std::collections::{HashMap, HashSet}; pub(crate) mod wire; -const DEFAULT_ROWS: usize = 5; -const DEFAULT_COLS: usize = 2048; -const DEFAULT_LAYERS: usize = 8; +pub(crate) const DEFAULT_ROWS: usize = 5; +pub(crate) const DEFAULT_COLS: usize = 2048; +pub(crate) const DEFAULT_LAYERS: usize = 8; #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub(crate) enum UnivMonUpdateMode { @@ -184,6 +184,7 @@ impl UnivMon { current if current == mode => {} _ => panic!("cannot mix standard and terminal-only updates in one UnivMon"), } + crate::analytics::ops::add(1); self.total_weight = self .total_weight .checked_add(value as usize) @@ -284,6 +285,7 @@ impl UnivMon { for i in 0..=bottom_layer_num { let count = self.layer_sketches[i].update_weighted_and_estimate(key, value); if !self.layer_heaps[i].update(key, count as i64) { + crate::analytics::ops::add(1); self.candidate_complete[i] = false; } } @@ -333,6 +335,7 @@ impl UnivMon { where F: Fn(f64) -> f64, { + crate::analytics::ops::add(1); if self.total_weight == 0 && !count_every_key { return 0.0; } @@ -341,6 +344,8 @@ impl UnivMon { } let counted = |count: i64| count_every_key || count > 0; + // Each `y` slot is written once and read once. + crate::analytics::ops::add(2 * self.layers); let mut y = vec![0.0; self.layers]; let mut tmp: f64; @@ -460,6 +465,7 @@ impl UnivMon { /// Returns the exact L1 norm for the supported non-negative update stream. pub fn estimate_l1(&self) -> f64 { + crate::analytics::ops::add(1); self.total_weight as f64 } @@ -478,6 +484,7 @@ impl UnivMon { /// Returns the estimated Shannon entropy, in bits (log2). pub fn estimate_entropy(&self) -> f64 { + crate::analytics::ops::add(1); if self.total_weight == 0 { return 0.0; } @@ -487,6 +494,7 @@ impl UnivMon { }, false, ); + crate::analytics::ops::add(2); (self.total_weight as f64).log2() - tmp / (self.total_weight as f64) } @@ -532,27 +540,34 @@ impl UnivMon { "cannot merge standard and terminal-only UnivMon states" ), } + crate::analytics::ops::add(2); self.total_weight = self .total_weight .checked_add(other.total_weight) .expect("merged UnivMon total weight overflowed usize"); for i in 0..self.layers { + crate::analytics::ops::add(2); let sources_complete = self.candidate_complete[i] && other.candidate_complete[i]; let candidate_keys: HashSet = self.layer_heaps[i] .heap() .iter() .chain(other.layer_heaps[i].heap()) - .map(|item| item.key.clone()) + .map(|item| { + crate::analytics::ops::add(1); + item.key.clone() + }) .collect(); let merged_candidates_complete = sources_complete && candidate_keys.len() <= self.heap_size; self.layer_sketches[i].merge(&other.layer_sketches[i]); self.layer_heaps[i].clear(); for key in candidate_keys { + crate::analytics::ops::add(1); let input = key.as_data_input(); let count = self.layer_sketches[i].estimate(&input) as i64; self.layer_heaps[i].update(&input, count); } + crate::analytics::ops::add(1); self.candidate_complete[i] = merged_candidates_complete; } } @@ -582,22 +597,30 @@ impl UnivMon { + self.candidate_complete.capacity() * size_of::() } + /// `C_in`: the cells a clone or clear copies, the heap bytes over 8. + #[cfg(feature = "op-count")] + pub(crate) fn cells(&self) -> usize { + use crate::analytics::frameworks::{InnerSketch, heap_cells}; + let cell = ::cell_bytes(&Default::default()); + heap_cells(self.memory_bytes(), size_of::(), cell) + } + /// Bytes a pyramid of that shape, built by [`Self::new`], decoded or cloned, /// holds in steady state once keys shaped like `key` churn through every /// layer: the struct and, per layer, - /// [`CountSketchWithL2Norm::estimated_memory_bytes`], - /// [`TopKHeap::estimated_memory_bytes`] and a candidate flag. A pyramid + /// [`CountSketchWithL2Norm::theoretical_memory_bytes`], + /// [`TopKHeap::theoretical_memory_bytes`] and a candidate flag. A pyramid /// holds at most this, and exactly this once every layer's heap has /// churned. Saturates at `usize::MAX`. - pub fn estimated_memory_bytes( + pub fn theoretical_memory_bytes( heap_size: usize, rows: usize, cols: usize, layers: usize, key: &DataInput, ) -> usize { - let layer = CountSketchWithL2Norm::estimated_memory_bytes(rows, cols) - .saturating_add(TopKHeap::::estimated_memory_bytes(heap_size, key)) + let layer = CountSketchWithL2Norm::theoretical_memory_bytes(rows, cols) + .saturating_add(TopKHeap::::theoretical_memory_bytes(heap_size, key)) .saturating_add(size_of::()); size_of::().saturating_add(layers.saturating_mul(layer)) } @@ -645,6 +668,7 @@ impl UnivMonPool { /// Pops a recycled sketch from the free-list if available, otherwise /// allocates a fresh one. pub fn take(&mut self) -> UnivMon { + crate::analytics::ops::add(1); if let Some(sketch) = self.free_list.pop() { sketch } else { @@ -655,6 +679,8 @@ impl UnivMonPool { /// Returns a sketch to the pool for reuse. Resets all internal state. pub fn put(&mut self, mut sketch: UnivMon) { + #[cfg(feature = "op-count")] + crate::analytics::ops::add(1 + sketch.cells()); sketch.clear(); self.free_list.push(sketch); } @@ -668,6 +694,18 @@ impl UnivMonPool { pub fn total_allocated(&self) -> usize { self.total_allocated } + + /// Bytes this pool holds: the struct, its free-list slots and each free + /// sketch's heap. + pub fn memory_bytes(&self) -> usize { + size_of::() + + self.free_list.capacity() * size_of::() + + self + .free_list + .iter() + .map(|s| s.memory_bytes() - size_of::()) + .sum::() + } } #[cfg(test)] @@ -1407,7 +1445,7 @@ mod tests { const EPS: f64 = 0.5; // L >= ceil(log2(F0 / k)) + 2. let layers = (KEYS as f64 / HEAP as f64).log2().ceil() as usize + 2; - let chebyshev = 2.0 / (HEAP as f64 * EPS * EPS); + let chebyshev = 1.35 / (HEAP as f64 * EPS * EPS); let mut rng = StdRng::seed_from_u64(0x5EED_00F0); let f0 = KEYS as f64; for shape in SHAPES { diff --git a/src/sketch_framework/univmon_q.rs b/src/sketch_framework/univmon_q.rs index cf9ba097..98759e53 100644 --- a/src/sketch_framework/univmon_q.rs +++ b/src/sketch_framework/univmon_q.rs @@ -25,7 +25,7 @@ use serde::{Deserialize, Serialize}; mod wire; -use crate::common::heap::hash_map_bytes; +use crate::common::heap::{hash_index_bytes, hash_map_bytes}; use crate::common::input::data_input_to_f64; use crate::common::numerical::NumericalValue; use crate::{DataInput, DefaultXxHasher, SketchHasher}; @@ -267,6 +267,7 @@ impl PackedCountSketch { #[inline(always)] fn update(&mut self, hash: u128, bucket_bits: u32) { + crate::analytics::ops::add(self.depth); match &mut self.counters { Counters::I32(counters) => { update_rows(counters, self.width, self.depth, hash, bucket_bits) @@ -297,6 +298,8 @@ impl PackedCountSketch { &mut estimates, ), } + crate::analytics::ops::add(2 * self.depth); + crate::analytics::ops::add_sort(self.depth); estimates[..self.depth].sort_unstable(); estimates[self.depth / 2].max(0) } @@ -312,6 +315,7 @@ impl PackedCountSketch { } fn merge(&mut self, other: &Self) { + crate::analytics::ops::add(2 * self.width * self.depth); match (&mut self.counters, &other.counters) { (Counters::I32(left), Counters::I32(right)) => { for (left, right) in left.iter_mut().zip(right) { @@ -449,7 +453,14 @@ fn f2_rows(counters: &[T], width: usize, depth: usize, rows: &mut Ve } } -#[derive(Clone, Debug)] +/// A clone of `heap` with the same capacity. +fn clone_reserved(heap: &BinaryHeap) -> BinaryHeap { + let mut clone = heap.clone(); + clone.reserve_exact(heap.capacity() - clone.len()); + clone +} + +#[derive(Debug)] struct Layer { sketch: PackedCountSketch, candidate_scores: HashMap, @@ -460,6 +471,19 @@ struct Layer { ever_evicted: bool, } +impl Clone for Layer { + fn clone(&self) -> Self { + Self { + sketch: self.sketch.clone(), + candidate_scores: self.candidate_scores.clone(), + candidate_heap: clone_reserved(&self.candidate_heap), + candidate_capacity: self.candidate_capacity, + candidate_table_peak: self.candidate_table_peak, + ever_evicted: self.ever_evicted, + } + } +} + impl Layer { fn new(width: usize, depth: usize, counter_bits: u8, candidates: usize) -> Self { let candidate_scores = HashMap::with_capacity(candidates); @@ -475,6 +499,7 @@ impl Layer { /// Inserts a candidate, tracking the table's allocation. fn insert_candidate(&mut self, key: u64, score: u64) { + crate::analytics::ops::add(1); self.candidate_scores.insert(key, score); self.candidate_table_peak = self .candidate_table_peak @@ -491,68 +516,92 @@ impl Layer { fn update(&mut self, key: u64, hash: u128, bucket_bits: u32) { self.sketch.update(hash, bucket_bits); let score = self.sketch.estimate(hash, bucket_bits).max(0) as u64; + crate::analytics::ops::add(1); if let Some(stored_score) = self.candidate_scores.get_mut(&key) { *stored_score = score; return; } if self.candidate_scores.len() < self.candidate_capacity { self.insert_candidate(key, score); - self.candidate_heap.push(Reverse((score, key))); + self.push_candidate(Reverse((score, key))); return; } if let Some((minimum, evicted)) = self.lightest_candidate() { + crate::analytics::ops::add(1); self.ever_evicted = true; if score > minimum { + crate::analytics::ops::add(1); self.candidate_scores.remove(&evicted); self.insert_candidate(key, score); - self.candidate_heap.push(Reverse((score, key))); + self.push_candidate(Reverse((score, key))); } else { - self.candidate_heap.push(Reverse((minimum, evicted))); + self.push_candidate(Reverse((minimum, evicted))); } } } + fn push_candidate(&mut self, entry: Reverse<(u64, u64)>) { + count_binary_heap_push(self.candidate_heap.len() + 1); + self.candidate_heap.push(entry); + } + + fn pop_candidate(&mut self) { + count_binary_heap_pop(self.candidate_heap.len()); + self.candidate_heap.pop(); + } + fn lightest_candidate(&mut self) -> Option<(u64, u64)> { loop { + crate::analytics::ops::add(1); let Reverse((stored_score, key)) = *self.candidate_heap.peek()?; + crate::analytics::ops::add(1); let Some(¤t_score) = self.candidate_scores.get(&key) else { - self.candidate_heap.pop(); + self.pop_candidate(); continue; }; if current_score != stored_score { - self.candidate_heap.pop(); - self.candidate_heap.push(Reverse((current_score, key))); + self.pop_candidate(); + self.push_candidate(Reverse((current_score, key))); continue; } - self.candidate_heap.pop(); + self.pop_candidate(); return Some((stored_score, key)); } } fn merge(&mut self, other: &Self, bucket_bits: u32, hash_key: impl Fn(u64) -> u128) { self.sketch.merge(&other.sketch); + // Each candidate key is read from its table and inserted into `combined`. + crate::analytics::ops::add( + 2 * (self.candidate_scores.len() + other.candidate_scores.len()), + ); let mut combined: HashSet = self.candidate_scores.keys().copied().collect(); combined.extend(other.candidate_scores.keys().copied()); let combined_len = combined.len(); let mut retained: Vec<(u64, u64)> = combined .into_iter() .map(|key| { + crate::analytics::ops::add(2); let hash = hash_key(key); let score = self.sketch.estimate(hash, bucket_bits).max(0) as u64; (key, score) }) .collect(); + crate::analytics::ops::add_sort(retained.len()); retained.sort_unstable_by(|left, right| { right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0)) }); retained.truncate(self.candidate_capacity); + crate::analytics::ops::add(2); self.ever_evicted = self.ever_evicted || other.ever_evicted || combined_len > self.candidate_capacity; + crate::analytics::ops::add(2); self.candidate_scores.clear(); self.candidate_heap.clear(); for (key, count) in retained { + crate::analytics::ops::add(1); self.insert_candidate(key, count); - self.candidate_heap.push(Reverse((count, key))); + self.push_candidate(Reverse((count, key))); } } @@ -569,7 +618,7 @@ impl Layer { /// `H` is the same pluggable hash abstraction used throughout Sketchlib. The /// default uses [`DefaultXxHasher`]. Values use `f64::total_cmp` ordering, /// including distinct `-0.0`/`0.0` and deterministic placement of NaNs. -#[derive(Clone, Debug)] +#[derive(Debug)] pub struct UnivMonQ { config: UnivMonQConfig, hash_layout: HashLayout, @@ -583,6 +632,23 @@ pub struct UnivMonQ { hasher: PhantomData, } +impl Clone for UnivMonQ { + fn clone(&self) -> Self { + Self { + config: self.config, + hash_layout: self.hash_layout, + layers: self.layers.clone(), + count: self.count, + min: self.min, + max: self.max, + source_id: self.source_id, + next_sequence: self.next_sequence, + ordered_heap: clone_reserved(&self.ordered_heap), + hasher: PhantomData, + } + } +} + impl<'a, H: SketchHasher> UnivMonQQuery<'a, H> { /// Number of observations in the prepared sketch snapshot. pub fn count(&self) -> u64 { @@ -876,6 +942,8 @@ impl UnivMonQ { #[inline(always)] fn update_value(&mut self, value: f64) { + // The sequence number is read, then incremented; count, min and max are updated. + crate::analytics::ops::add(5); let key = float_to_ordered(value); let hash = self.hash_key(key); let sequence = self.next_sequence; @@ -901,6 +969,7 @@ impl UnivMonQ { "cannot merge UnivMon-Q sketches with different configurations", )); } + crate::analytics::ops::add(6); self.count = self.count.saturating_add(other.count); self.min = match (self.min, other.min) { (Some(left), Some(right)) => Some(left.min(right)), @@ -959,6 +1028,7 @@ impl UnivMonQ { /// CountSketch point-frequency estimate for a numeric value. pub fn estimate_frequency(&self, value: f64) -> u64 { + crate::analytics::ops::add(1); if self.is_empty() { return 0; } @@ -1252,6 +1322,33 @@ impl UnivMonQ { + self.ordered_heap.capacity() * size_of::() } + /// Bytes a sketch with `config` holds once each layer's candidate table + /// has churned: as [`Self::memory_bytes`] counts them, at full candidate + /// tables and heaps. Saturates at `usize::MAX`. + pub fn theoretical_memory_bytes(config: UnivMonQConfig) -> usize { + let counter_bytes = usize::from(config.counter_bits / 8); + let counters = (0..config.layers) + .map(|level| layer_cols(config, level)) + .fold(0usize, |sum, cols| sum.saturating_add(cols)) + .saturating_mul(config.rows) + .saturating_mul(counter_bytes); + let candidates = hash_index_bytes::<(u64, u64)>(config.heap_size, config.heap_size) + .saturating_add( + config + .heap_size + .saturating_mul(size_of::>()), + ); + size_of::() + .saturating_add(config.layers.saturating_mul(size_of::())) + .saturating_add(counters) + .saturating_add(config.layers.saturating_mul(candidates)) + .saturating_add( + config + .ordered_samples + .saturating_mul(size_of::()), + ) + } + fn cdf_keys(&self) -> Vec<(u64, f64)> { if self.is_empty() || self.config.ordered_samples == 0 { return Vec::new(); @@ -1467,15 +1564,19 @@ impl UnivMonQ { return; } if self.ordered_heap.len() < capacity { + count_binary_heap_push(self.ordered_heap.len() + 1); self.ordered_heap.push(occurrence); return; } + crate::analytics::ops::add(1); if self .ordered_heap .peek() .is_some_and(|largest| occurrence < *largest) { + count_binary_heap_pop(self.ordered_heap.len()); self.ordered_heap.pop(); + count_binary_heap_push(self.ordered_heap.len() + 1); self.ordered_heap.push(occurrence); } } @@ -1486,6 +1587,7 @@ impl UnivMonQ { return; } for &occurrence in &other.ordered_heap { + crate::analytics::ops::add(1); self.update_ordered_sample(occurrence); } } @@ -1562,6 +1664,11 @@ fn allocate_source_id() -> u64 { .expect("UnivMon-Q automatic source IDs exhausted") } +/// Whether [`UnivMonQ::with_hasher`] accepts `config`. +pub(crate) fn config_is_valid(config: UnivMonQConfig) -> bool { + validate_config(config).is_ok() && HashLayout::new(config).is_ok() +} + fn validate_config(config: UnivMonQConfig) -> Result<(), UnivMonQError> { if !(2..=63).contains(&config.layers) { return Err(UnivMonQError::new("layers must be in 2..=63")); @@ -1581,7 +1688,36 @@ fn validate_config(config: UnivMonQConfig) -> Result<(), UnivMonQError> { Ok(()) } -fn layer_cols(config: UnivMonQConfig, level: usize) -> usize { +/// Ops a `BinaryHeap` push counts when the heap then holds `len` entries: +/// the appended slot, its re-read, a parent read and a move per level, and +/// the final write. +#[inline(always)] +fn count_binary_heap_push(len: usize) { + crate::analytics::ops::add(binary_heap_push_ops(len)); +} + +fn binary_heap_push_ops(len: usize) -> usize { + 3 + 2 * binary_heap_levels(len) +} + +/// Ops a `BinaryHeap` pop counts from `len` entries: the last slot, the root +/// read and written, two hole reads and two hole writes, two child reads and +/// a move per level down, and a parent read and a move per level back up. +#[inline(always)] +fn count_binary_heap_pop(len: usize) { + crate::analytics::ops::add(binary_heap_pop_ops(len)); +} + +fn binary_heap_pop_ops(len: usize) -> usize { + 7 + 5 * binary_heap_levels(len) +} + +#[inline(always)] +fn binary_heap_levels(len: usize) -> usize { + if len <= 1 { 0 } else { len.ilog2() as usize } +} + +pub(crate) fn layer_cols(config: UnivMonQConfig, level: usize) -> usize { let shift = if config.cols_halving_period == 0 { 0 } else { @@ -2628,4 +2764,96 @@ mod tests { assert_eq!(sketch.config(), tiny_config()); assert_eq!(sketch.quantile(0.5), None); } + + /// Accesses std's `BinaryHeap::push` makes, one per slot read or write + /// and one per hole move, on a max-heap mirrored step by step. + fn mirrored_push(data: &mut Vec, item: u32) -> usize { + data.push(item); + let mut pos = data.len() - 1; + let mut ops = 2; + while pos > 0 { + let parent = (pos - 1) / 2; + ops += 1; + if item <= data[parent] { + break; + } + data[pos] = data[parent]; + ops += 1; + pos = parent; + } + data[pos] = item; + ops + 1 + } + + /// Accesses std's `BinaryHeap::pop` makes: `sift_down_to_bottom`, then + /// `sift_up` from where the hole stopped. + fn mirrored_pop(data: &mut Vec) -> usize { + let mut item = data.pop().unwrap(); + let mut ops = 1; + if data.is_empty() { + return ops; + } + std::mem::swap(&mut item, &mut data[0]); + ops += 3; + let (end, element) = (data.len(), data[0]); + let (mut pos, mut child) = (0, 1); + while child + 2 <= end { + ops += 2; + child += usize::from(data[child] <= data[child + 1]); + data[pos] = data[child]; + ops += 1; + pos = child; + child = 2 * pos + 1; + } + if child + 1 == end { + data[pos] = data[child]; + ops += 1; + pos = child; + } + ops += 2; + while pos > 0 { + let parent = (pos - 1) / 2; + ops += 1; + if element <= data[parent] { + break; + } + data[pos] = data[parent]; + ops += 1; + pos = parent; + } + data[pos] = element; + ops + 1 + } + + #[test] + fn binary_heap_op_models_bound_std_push_and_pop() { + let mut state = 0x9e37_79b9_7f4a_7c15u64; + let mut next = move || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + (state % 64) as u32 + }; + let mut worst_pop_slack = usize::MAX; + for len in 1..200 { + for _ in 0..50 { + let mut heap = BinaryHeap::new(); + let mut mirror = Vec::new(); + for _ in 0..len { + let item = next(); + let ops = mirrored_push(&mut mirror, item); + heap.push(item); + assert!(ops <= binary_heap_push_ops(mirror.len())); + } + assert_eq!(heap.as_slice(), mirror.as_slice()); + let ops = mirrored_pop(&mut mirror); + heap.pop(); + assert_eq!(heap.as_slice(), mirror.as_slice()); + let model = binary_heap_pop_ops(len); + assert!(ops <= model, "pop from {len} made {ops}, model {model}"); + worst_pop_slack = worst_pop_slack.min(model - ops); + } + } + assert!(worst_pop_slack <= 1, "pop model slack {worst_pop_slack}"); + } } diff --git a/src/sketch_framework/univmon_q/wire.rs b/src/sketch_framework/univmon_q/wire.rs index ee6f8f4c..26877de0 100644 --- a/src/sketch_framework/univmon_q/wire.rs +++ b/src/sketch_framework/univmon_q/wire.rs @@ -508,8 +508,8 @@ impl UnivMonQ { let mut candidate = 0usize; for (index, &width) in widths.iter().enumerate() { let run = shared.candidate_lens[index] as usize; - let mut candidate_scores = HashMap::with_capacity(run); - let mut candidate_heap = BinaryHeap::with_capacity(run); + let mut candidate_scores = HashMap::with_capacity(config.heap_size); + let mut candidate_heap = BinaryHeap::with_capacity(config.heap_size); if shared.candidate_keys[candidate..candidate + run] .windows(2) .any(|pair| pair[0] >= pair[1]) @@ -541,6 +541,8 @@ impl UnivMonQ { cell += span; } + let mut ordered_heap = BinaryHeap::with_capacity(config.ordered_samples); + ordered_heap.extend(shared.occurrences); let sketch = UnivMonQ { config, hash_layout, @@ -550,7 +552,7 @@ impl UnivMonQ { max: shared.max, source_id: shared.source_id, next_sequence: shared.next_sequence, - ordered_heap: BinaryHeap::from(shared.occurrences), + ordered_heap, hasher: PhantomData, }; // A candidate only reaches a level its own key hashes into. diff --git a/src/sketch_framework/univmon_two_tier.rs b/src/sketch_framework/univmon_two_tier.rs index 8e47c8a9..cd98e619 100644 --- a/src/sketch_framework/univmon_two_tier.rs +++ b/src/sketch_framework/univmon_two_tier.rs @@ -18,12 +18,12 @@ mod wire; // UnivMonTwoTier // --------------------------------------------------------------------------- -const DEFAULT_ELEPHANT_LAYERS: usize = 8; -const DEFAULT_ELEPHANT_ROWS: usize = 3; -const DEFAULT_ELEPHANT_COLS: usize = 2048; -const DEFAULT_MOUSE_ROWS: usize = 3; -const DEFAULT_MOUSE_COLS: usize = 512; -const DEFAULT_LAYERS: usize = 16; +pub(crate) const DEFAULT_ELEPHANT_LAYERS: usize = 8; +pub(crate) const DEFAULT_ELEPHANT_ROWS: usize = 3; +pub(crate) const DEFAULT_ELEPHANT_COLS: usize = 2048; +pub(crate) const DEFAULT_MOUSE_ROWS: usize = 3; +pub(crate) const DEFAULT_MOUSE_COLS: usize = 512; +pub(crate) const DEFAULT_LAYERS: usize = 16; /// Pyramid-structured UnivMon with two-tier sketch dimensions. /// @@ -134,6 +134,7 @@ impl UnivMonTwoTier { current if current == mode => {} _ => panic!("cannot mix standard and terminal-only updates in one UnivMon"), } + crate::analytics::ops::add(1); self.total_weight = self .total_weight .checked_add(value as usize) @@ -161,6 +162,7 @@ impl UnivMonTwoTier { for i in 0..=bottom { let count = self.layer_sketches[i].update_weighted_and_estimate(key, weight); if !self.layer_heaps[i].update(key, count as i64) { + crate::analytics::ops::add(1); self.candidate_complete[i] = false; } } @@ -188,6 +190,7 @@ impl UnivMonTwoTier { where F: Fn(f64) -> f64, { + crate::analytics::ops::add(1); if self.total_weight == 0 && !count_every_key { return 0.0; } @@ -196,6 +199,8 @@ impl UnivMonTwoTier { } let counted = |count: i64| count_every_key || count > 0; + // Each `y` slot is written once and read once. + crate::analytics::ops::add(2 * self.layers); let mut y = vec![0.0; self.layers]; let mut tmp = 0.0; @@ -311,6 +316,7 @@ impl UnivMonTwoTier { /// Returns the exact L1 norm for the supported non-negative update stream. pub fn estimate_l1(&self) -> f64 { + crate::analytics::ops::add(1); self.total_weight as f64 } @@ -329,10 +335,12 @@ impl UnivMonTwoTier { /// Returns the estimated Shannon entropy, in bits (log2). pub fn estimate_entropy(&self) -> f64 { + crate::analytics::ops::add(1); if self.total_weight == 0 { return 0.0; } let tmp = self.estimate_g_sum(|x| if x > 0.0 { x * x.log2() } else { 0.0 }, false); + crate::analytics::ops::add(2); (self.total_weight as f64).log2() - tmp / (self.total_weight as f64) } @@ -391,27 +399,34 @@ impl UnivMonTwoTier { "cannot merge standard and terminal-only UnivMon states" ), } + crate::analytics::ops::add(2); self.total_weight = self .total_weight .checked_add(other.total_weight) .expect("merged UnivMon total weight overflowed usize"); for i in 0..self.layers { + crate::analytics::ops::add(2); let sources_complete = self.candidate_complete[i] && other.candidate_complete[i]; let candidate_keys: HashSet = self.layer_heaps[i] .heap() .iter() .chain(other.layer_heaps[i].heap()) - .map(|item| item.key.clone()) + .map(|item| { + crate::analytics::ops::add(1); + item.key.clone() + }) .collect(); let merged_candidates_complete = sources_complete && candidate_keys.len() <= self.heap_size; self.layer_sketches[i].merge(&other.layer_sketches[i]); self.layer_heaps[i].clear(); for key in candidate_keys { + crate::analytics::ops::add(1); let input = key.as_data_input(); let count = self.layer_sketches[i].estimate(&input) as i64; self.layer_heaps[i].update(&input, count); } + crate::analytics::ops::add(1); self.candidate_complete[i] = merged_candidates_complete; } } @@ -420,6 +435,53 @@ impl UnivMonTwoTier { pub fn layer_heap_mut(&mut self, layer: usize) -> &mut TopKHeap { &mut self.layer_heaps[layer] } + + /// Bytes this pyramid holds: the struct, each layer's sketch and heap + /// ([`CountSketchWithL2Norm::memory_bytes`], [`TopKHeap::memory_bytes`]) + /// and the per-layer candidate flags. + pub fn memory_bytes(&self) -> usize { + size_of::() + + self.layer_sketches.capacity() * size_of::() + + self + .layer_sketches + .iter() + .map(|sketch| sketch.memory_bytes() - size_of::()) + .sum::() + + self.layer_heaps.capacity() * size_of::() + + self + .layer_heaps + .iter() + .map(|heap| heap.memory_bytes() - size_of::()) + .sum::() + + self.candidate_complete.capacity() * size_of::() + } + + /// Bytes a pyramid of that shape holds once keys shaped like `key` churn + /// through every layer, as [`UnivMon::theoretical_memory_bytes`] counts + /// them with each layer's own dimensions. Saturates at `usize::MAX`. + #[allow(clippy::too_many_arguments)] + pub fn theoretical_memory_bytes( + heap_size: usize, + elephant_layers: usize, + elephant_rows: usize, + elephant_cols: usize, + mouse_rows: usize, + mouse_cols: usize, + layers: usize, + key: &DataInput, + ) -> usize { + let per_layer = TopKHeap::::theoretical_memory_bytes(heap_size, key) + .saturating_add(size_of::()); + let elephants = elephant_layers.min(layers); + let elephant = + CountSketchWithL2Norm::theoretical_memory_bytes(elephant_rows, elephant_cols) + .saturating_add(per_layer); + let mouse = CountSketchWithL2Norm::theoretical_memory_bytes(mouse_rows, mouse_cols) + .saturating_add(per_layer); + size_of::() + .saturating_add(elephants.saturating_mul(elephant)) + .saturating_add((layers - elephants).saturating_mul(mouse)) + } } #[cfg(test)] @@ -561,8 +623,8 @@ mod tests { } /// Mouse layers much narrower than the elephant ones: the joint bound - /// `P(error > eps F0 AND complete) <= 2/(k eps^2)`, and the ideal estimator - /// (exact top count restored) has mean `F0` and variance at most `2 F0^2/k`. + /// `P(error > eps F0 AND complete) <= 1.35/(k eps^2)`, and the ideal estimator + /// (exact top count restored) has mean `F0` and variance at most `1.35 F0^2/k`. fn check_cardinality(trials: usize) { const HEAP: usize = 32; const KEYS: usize = 2000; @@ -598,15 +660,15 @@ mod tests { "top layer incomplete in {incomplete}/{trials} trials" ); let n = trials as f64; - let delta = 2.0 / (HEAP as f64 * EPS * EPS); + let delta = 1.35 / (HEAP as f64 * EPS * EPS); assert!( failures as f64 <= allowed_failures(trials, delta), "{failures}/{trials} joint misses beyond eps={EPS}" ); let variance = sum_sq / n; assert!( - variance <= 2.0 * f0 * f0 / HEAP as f64, - "variance {:.3} F0^2/k above 2", + variance <= 1.35 * f0 * f0 / HEAP as f64, + "variance {:.3} F0^2/k above 1.35", variance / (f0 * f0 / HEAP as f64) ); assert!( diff --git a/src/sketches/bloom.rs b/src/sketches/bloom.rs index a4d6332f..737c6b03 100644 --- a/src/sketches/bloom.rs +++ b/src/sketches/bloom.rs @@ -35,9 +35,6 @@ pub const BLOOM_DEFAULT_COLS: usize = 1 << 16; pub const BLOOM_MAX_SLICES: usize = MATRIX_MAX_ROWS; /// Ceiling on the bits [`Bloom::with_capacity`] will size to, 256 MiB packed. -/// -/// A target the ceiling cannot reach yields the widest slices that fit, and -/// [`Bloom::predicted_fpp`] then reports the rate those slices deliver. pub const BLOOM_MAX_BITS: usize = 1 << 31; /// Wire tag identifying the hash path a filter was built on. @@ -95,9 +92,15 @@ impl Bloom { /// See [`Self::dimensions_for`] for the sizing and its bounds. /// /// # Panics - /// If `target_fpp` is NaN or infinite. + /// If `target_fpp` is NaN or infinite, or no filter within + /// [`BLOOM_MAX_BITS`] meets it. pub fn with_capacity(expected_items: usize, target_fpp: f64) -> Self { - let (rows, cols) = Self::dimensions_for(expected_items, target_fpp); + let Some((rows, cols)) = Self::dimensions_for(expected_items, target_fpp) else { + panic!( + "no Bloom filter within BLOOM_MAX_BITS ({BLOOM_MAX_BITS}) bits holds \ + {expected_items} items at false-positive rate {target_fpp}" + ); + }; Self::with_dimensions(rows, cols) } @@ -107,20 +110,19 @@ impl Bloom { /// the bit budget `m = ceil(-n * ln(p) / ln(2)^2)` for `n = expected_items` /// (at least 1) at target `p`. Then the slice width that hits `p` with /// exactly that many slices, `cols = -n / ln(1 - p^(1/k))`, rounded up to a - /// power of two. Solving for the capped `k` rather than assuming the `k`-optimal - /// split is what keeps a small target reachable: the cap costs bits, not - /// accuracy, until [`BLOOM_MAX_BITS`] binds. + /// power of two, then doubled until [`Self::predicted_fpp`] for `n` items is + /// at most `p`. Solving for the capped `k` rather than assuming the + /// `k`-optimal split is what keeps a small target reachable: the cap costs + /// bits, not accuracy. /// - /// The power-of-two rounding keeps the column fold free of modulo bias, so - /// the measured rate lands at or under the target rather than above it. - /// `target_fpp` is clamped into `(0, 1)` and both dimensions floor at 1. A - /// target needing more than [`BLOOM_MAX_BITS`] gets the widest slices that - /// fit, and [`Self::predicted_fpp`] reports the rate they deliver. + /// The power-of-two width keeps the column fold free of modulo bias. + /// `target_fpp` is clamped into `(0, 1)` and both dimensions floor at 1. + /// `None` when meeting the target needs more than [`BLOOM_MAX_BITS`] bits. /// /// # Panics /// If `target_fpp` is NaN or infinite. Clamping cannot order those, and a /// silently degenerate filter answers yes to everything. - pub fn dimensions_for(expected_items: usize, target_fpp: f64) -> (usize, usize) { + pub fn dimensions_for(expected_items: usize, target_fpp: f64) -> Option<(usize, usize)> { assert!( target_fpp.is_finite(), "target false-positive rate must be finite, got {target_fpp}" @@ -134,12 +136,18 @@ impl Bloom { let per_slice_fill = p.powf(1.0 / rows as f64); let wanted = (-n / (1.0 - per_slice_fill).ln()).ceil().max(1.0); let widest = 1usize << (BLOOM_MAX_BITS / rows).max(1).ilog2(); - let cols = if wanted >= widest as f64 { + let mut cols = if wanted >= widest as f64 { widest } else { (wanted as usize).next_power_of_two().min(widest) }; - (rows, cols) + while partitioned_fpp(rows, cols, n) > p { + if cols >= widest { + return None; + } + cols *= 2; + } + Some((rows, cols)) } /// Number of hash functions. @@ -164,6 +172,19 @@ impl Bloom { self.bits.size_in_bytes() } + /// Bytes this filter holds: the struct and its packed words. + pub fn memory_bytes(&self) -> usize { + size_of::() + self.bits.size_in_bytes() + } + + /// Bytes a filter of `rows` slices of `cols` bits holds: the struct and + /// `rows·⌈cols/64⌉` words. Saturates at `usize::MAX`. + pub fn theoretical_memory_bytes(rows: usize, cols: usize) -> usize { + rows.saturating_mul(cols.div_ceil(64)) + .saturating_mul(size_of::()) + .saturating_add(size_of::()) + } + /// Fraction of bits set. pub fn fill_ratio(&self) -> f64 { self.bits.fill_ratio() @@ -192,12 +213,10 @@ impl Bloom { self.fill_ratio().powi(self.rows() as i32) } - /// False-positive rate the sizing formula predicts for `distinct_items` - /// distinct keys, `(1 - e^(-n/cols))^rows`. + /// False-positive rate for `distinct_items` distinct keys, + /// `(1 - (1 - 1/cols)^n)^rows`, exact for a power-of-two `cols`. pub fn predicted_fpp(&self, distinct_items: usize) -> f64 { - let n = distinct_items as f64; - let per_slice = 1.0 - (-n / self.cols() as f64).exp(); - per_slice.powi(self.rows() as i32) + partitioned_fpp(self.rows(), self.cols(), distinct_items as f64) } /// Unions `other` into `self`. @@ -208,6 +227,11 @@ impl Bloom { self.bits.union_from(&other.bits); } } +pub(crate) fn partitioned_fpp(rows: usize, cols: usize, items: f64) -> f64 { + let per_slice = -(items * (-1.0 / cols as f64).ln_1p()).exp_m1(); + per_slice.powi(rows as i32) +} + // Regular-path membership: one seeded hash per slice. impl Bloom { /// Records `value` as a member. @@ -288,7 +312,7 @@ mod tests { fn with_capacity_builds_exactly_the_geometry_dimensions_for_reports() { for expected in [0usize, 1, 17, 1_000, 250_000] { for target in [0.5, 0.1, 0.01, 1e-4, 1e-6] { - let (rows, cols) = Bloom::::dimensions_for(expected, target); + let (rows, cols) = Bloom::::dimensions_for(expected, target).unwrap(); let built = Bloom::::with_capacity(expected, target); assert_eq!( (built.rows(), built.cols()), @@ -311,7 +335,7 @@ mod tests { [(1usize, 0.05, 5usize), (1, 1.0 / 128.0, 8), (2, 1e-4, 14)] { let exponent = (1.0f64 / target).log2().round() as usize; - let (rows, _) = Bloom::::dimensions_for(expected, target); + let (rows, _) = Bloom::::dimensions_for(expected, target).unwrap(); assert_eq!( rows, slices, "dimensions_for({expected}, {target}) chose {rows} slices, not {slices}" @@ -330,7 +354,7 @@ mod tests { .round() .clamp(1.0, BLOOM_MAX_SLICES as f64) as usize; for expected in [1_000usize, 100_000, 10_000_000] { - let (rows, _) = Bloom::::dimensions_for(expected, target); + let (rows, _) = Bloom::::dimensions_for(expected, target).unwrap(); assert_eq!( rows, exponent, "dimensions_for({expected}, {target}) chose {rows} slices, \ @@ -339,20 +363,20 @@ mod tests { } } - let (capped, _) = Bloom::::dimensions_for(1_000_000, 1e-12); + let (capped, _) = Bloom::::dimensions_for(1_000_000, 1e-12).unwrap(); assert_eq!( capped, BLOOM_MAX_SLICES, "a target needing more slices than the seed list holds chose {capped}" ); } - /// A target outside `(0, 1)` is clamped rather than rejected, and the clamp - /// lands on the interval's own endpoints: a caller who passes `0.0` must get - /// the sharpest filter the sizing can express, not a degenerate one. + /// A target outside `(0, 1)` is clamped to the interval's own endpoints, + /// and the lowest one needs more bits than any filter has. #[test] fn a_target_outside_the_open_unit_interval_is_clamped_to_the_endpoints() { let lowest = Bloom::::dimensions_for(1_000, f64::MIN_POSITIVE); let highest = Bloom::::dimensions_for(1_000, 1.0 - f64::EPSILON); + assert_eq!(lowest, None, "a target of 2^-1022 was met"); for below in [0.0, -0.0, -1.0, f64::MIN] { assert_eq!( Bloom::::dimensions_for(1_000, below), @@ -367,13 +391,23 @@ mod tests { "target {above} did not clamp down to the largest rate below one {highest:?}" ); } - for (rows, cols) in [lowest, highest] { - assert!(rows >= 1 && cols >= 1, "clamping produced {rows}x{cols}"); - assert!( - cols.is_power_of_two(), - "clamped cols {cols} is not a power of two" - ); - } + let (rows, cols) = highest.unwrap(); + assert!(rows >= 1 && cols >= 1, "clamping produced {rows}x{cols}"); + assert!( + cols.is_power_of_two(), + "clamped cols {cols} is not a power of two" + ); + } + + #[test] + fn a_target_past_the_bit_ceiling_is_unreachable() { + assert_eq!(Bloom::::dimensions_for(1 << 30, 1e-12), None); + } + + #[test] + #[should_panic(expected = "no Bloom filter within BLOOM_MAX_BITS")] + fn with_capacity_panics_on_an_unreachable_target() { + Bloom::::with_capacity(1 << 30, 1e-12); } /// A NaN target cannot be ordered, so clamping it would silently produce a @@ -396,14 +430,14 @@ mod tests { /// that would panic the moment a key arrives. #[test] fn sizing_for_zero_expected_items_still_yields_a_filter_that_answers() { - let (rows, cols) = Bloom::::dimensions_for(0, 0.01); + let (rows, cols) = Bloom::::dimensions_for(0, 0.01).unwrap(); assert!( rows >= 1 && cols >= 1, "sizing for nothing gave {rows}x{cols}" ); assert_eq!( (rows, cols), - Bloom::::dimensions_for(1, 0.01), + Bloom::::dimensions_for(1, 0.01).unwrap(), "sizing for zero items differs from sizing for one" ); @@ -489,10 +523,10 @@ mod tests { "estimated_fpp did not use the slice count" ); - let per_slice = 1.0 - (-400.0f64 / WIDE_COLS as f64).exp(); - assert_eq!( - filter.predicted_fpp(400), - per_slice.powi(rows as i32), + let per_slice = 1.0 - (1.0 - 1.0 / WIDE_COLS as f64).powi(400); + let expected = per_slice.powi(rows as i32); + assert!( + (filter.predicted_fpp(400) / expected - 1.0).abs() < 1e-12, "predicted_fpp did not use the slice count" ); } diff --git a/src/sketches/coco.rs b/src/sketches/coco.rs index 84e9c54f..70fa398e 100644 --- a/src/sketches/coco.rs +++ b/src/sketches/coco.rs @@ -68,11 +68,11 @@ impl CocoRng { /// Buckets per array in a default `CocoSketch`. Width sets the collision rate, and /// `rows * cols` buckets at 32 bytes each set the table footprint. -const DEFAULT_WIDTH: usize = 1024; +pub(crate) const DEFAULT_WIDTH: usize = 1024; /// Arrays in a default `CocoSketch`, inside the paper's recommended 2..=4. Every /// update scans the `rows` buckets its key maps to. -const DEFAULT_DEPTH: usize = 4; +pub(crate) const DEFAULT_DEPTH: usize = 4; impl Default for CocoBucket { fn default() -> Self { @@ -166,6 +166,18 @@ impl CocoSketch { } } + /// Bytes this sketch holds: the struct, the bucket table and the stored + /// keys' owned bytes. + pub fn memory_bytes(&self) -> usize { + let keys: usize = self + .table + .as_slice() + .iter() + .filter_map(|b| b.full_key.as_ref().map(String::capacity)) + .sum(); + size_of::() + size_of_val(self.table.as_slice()) + keys + } + /// Adds `v` to `key` using the paper's stochastic variance-optimized update. /// /// The `rows` mapped buckets are scanned for `key` first; a match absorbs `v` @@ -183,6 +195,7 @@ impl CocoSketch { for i in 0..self.rows { let idx = H::hash64_seeded(i, &key_input) as usize % self.cols; + crate::analytics::ops::add(1); let bucket = &self.table[i][idx]; if bucket.full_key.as_deref() == Some(key) { self.table[i][idx].val += weight; @@ -202,6 +215,7 @@ impl CocoSketch { } } + crate::analytics::ops::add(1); let bucket = &mut self.table[victim.0][victim.1]; bucket.val += weight; let elected = match bucket.full_key { @@ -226,6 +240,7 @@ impl CocoSketch { let mut total = 0; for i in 0..self.rows { let idx = H::hash64_seeded(i, &key_input) as usize % self.cols; + crate::analytics::ops::add(1); if self.table[i][idx].full_key.as_deref() == Some(key) { total += self.table[i][idx].val; } @@ -239,6 +254,7 @@ impl CocoSketch { pub fn recorded_flows(&self) -> impl Iterator { (0..self.rows).flat_map(move |i| { (0..self.cols).filter_map(move |j| { + crate::analytics::ops::add(1); let bucket = &self.table[i][j]; bucket.full_key.as_deref().map(|key| (key, bucket.val)) }) @@ -296,6 +312,7 @@ impl CocoSketch { assert_eq!(self.cols, other.cols, "Different cols, do nothing"); for i in 0..self.rows { for j in 0..self.cols { + crate::analytics::ops::add(1); if let Some(k) = &other.table[i][j].full_key { self.update_weighted(k.as_str(), other.table[i][j].val); } diff --git a/src/sketches/count_min_fold.rs b/src/sketches/count_min_fold.rs index 74d4d716..72e48ab7 100644 --- a/src/sketches/count_min_fold.rs +++ b/src/sketches/count_min_fold.rs @@ -20,6 +20,9 @@ use std::marker::PhantomData; const LOWER_32_MASK: u64 = (1u64 << 32) - 1; +/// The widest `full_cols`, since a full column is stored as `u16`. +pub const MAX_FULL_COLS: usize = 1 << 16; + // --------------------------------------------------------------------------- // FoldEntry / FoldCell // --------------------------------------------------------------------------- @@ -60,6 +63,7 @@ impl FoldCell { /// representation only when a genuine collision is detected. #[inline] pub fn add(&mut self, full_col: u16, delta: i64) { + crate::analytics::ops::add(1); match self { FoldCell::Empty => { *self = FoldCell::Single { @@ -75,6 +79,7 @@ impl FoldCell { *count += delta; } else { // Real collision — upgrade to Collided. + crate::analytics::ops::add(2); let existing = FoldEntry { full_col: *existing_col, count: *count, @@ -88,11 +93,13 @@ impl FoldCell { } FoldCell::Collided(entries) => { for entry in entries.iter_mut() { + crate::analytics::ops::add(1); if entry.full_col == full_col { entry.count += delta; return; } } + crate::analytics::ops::add(1); entries.push(FoldEntry { full_col, count: delta, @@ -104,6 +111,7 @@ impl FoldCell { /// Look up the counter for a specific `full_col`. Returns 0 when absent. #[inline] pub fn get(&self, full_col: u16) -> i64 { + crate::analytics::ops::add(1); match self { FoldCell::Empty => 0, FoldCell::Single { @@ -118,6 +126,7 @@ impl FoldCell { } FoldCell::Collided(entries) => { for entry in entries { + crate::analytics::ops::add(1); if entry.full_col == full_col { return entry.count; } @@ -129,6 +138,7 @@ impl FoldCell { /// Merge another cell's entries into this cell (same fold level). pub fn merge_from(&mut self, other: &FoldCell) { + crate::analytics::ops::add(1); match other { FoldCell::Empty => {} FoldCell::Single { full_col, count } => { @@ -136,6 +146,7 @@ impl FoldCell { } FoldCell::Collided(entries) => { for entry in entries { + crate::analytics::ops::add(1); self.add(entry.full_col, entry.count); } } @@ -156,6 +167,14 @@ impl FoldCell { matches!(self, FoldCell::Empty) } + /// Bytes this cell's entry list holds outside the cell. + pub(crate) fn list_bytes(&self) -> usize { + match self { + FoldCell::Collided(entries) => entries.capacity() * size_of::(), + _ => 0, + } + } + /// Iterate over all `(full_col, count)` pairs in this cell. pub fn iter(&self) -> FoldCellIter<'_> { match self { @@ -227,12 +246,17 @@ impl CountMinSketchFold { /// /// # Panics /// - /// Panics if `full_cols` is not a power of two or `fold_level` is too large. + /// Panics if `full_cols` is not a power of two, is above 65536, or + /// `fold_level` is too large. pub fn new(rows: usize, full_cols: usize, fold_level: u32, heap_size: usize) -> Self { assert!( full_cols.is_power_of_two(), "full_cols must be a power of two, got {full_cols}" ); + assert!( + full_cols <= MAX_FULL_COLS, + "full_cols must be at most {MAX_FULL_COLS}, got {full_cols}" + ); assert!( fold_level <= full_cols.trailing_zeros(), "fold_level {fold_level} too large for full_cols {full_cols}" @@ -305,6 +329,16 @@ impl CountMinSketchFold { &self.cells[row * self.fold_cols + fold_col] } + /// Bytes this sketch holds: the struct, the allocated cells, the entry + /// lists of collided cells and [`TopKHeap::memory_bytes`]. + pub fn memory_bytes(&self) -> usize { + let lists: usize = self.cells.iter().map(FoldCell::list_bytes).sum(); + size_of::() - size_of::() + + self.cells.capacity() * size_of::() + + lists + + self.heap.memory_bytes() + } + /// Total number of `(full_col, count)` entries across all cells. pub fn total_entries(&self) -> usize { self.cells.iter().map(|c| c.entry_count()).sum() @@ -405,6 +439,11 @@ impl CountMinSketchFold { let dst_row_off = r * target_fold_cols; for c in 0..self.fold_cols { let cell = &self.cells[src_row_off + c]; + let listed = match cell { + FoldCell::Collided(entries) => entries.len(), + _ => 0, + }; + crate::analytics::ops::add(1 + listed); for (full_col, count) in cell.iter() { let new_fc = (full_col as usize) & (target_fold_cols - 1); target.cells[dst_row_off + new_fc].add(full_col, count); @@ -437,7 +476,10 @@ impl CountMinSketchFold { fold_cols: new_fold_cols, full_cols: a.full_cols, fold_level: new_level, - cells: vec![FoldCell::Empty; a.rows * new_fold_cols], + cells: { + crate::analytics::ops::add(a.rows * new_fold_cols); + vec![FoldCell::Empty; a.rows * new_fold_cols] + }, heap: TopKHeap::new(heap_k), _hasher: PhantomData, }; @@ -487,7 +529,10 @@ impl CountMinSketchFold { fold_cols: new_fold_cols, full_cols: self.full_cols, fold_level: target_level, - cells: vec![FoldCell::Empty; self.rows * new_fold_cols], + cells: { + crate::analytics::ops::add(self.rows * new_fold_cols); + vec![FoldCell::Empty; self.rows * new_fold_cols] + }, heap: TopKHeap::new(self.heap.capacity()), _hasher: PhantomData, }; @@ -526,7 +571,10 @@ impl CountMinSketchFold { fold_cols: full_cols, full_cols, fold_level: 0, - cells: vec![FoldCell::Empty; rows * full_cols], + cells: { + crate::analytics::ops::add(rows * full_cols); + vec![FoldCell::Empty; rows * full_cols] + }, heap: TopKHeap::new(heap_k), _hasher: PhantomData, }; @@ -751,6 +799,12 @@ mod tests { let _: CountMinSketchFold = CountMinSketchFold::new(3, 1000, 0, 10); } + #[test] + #[should_panic(expected = "full_cols must be at most 65536")] + fn count_min_sketch_fold_rejects_more_columns_than_u16_holds() { + let _: CountMinSketchFold = CountMinSketchFold::new(3, 1 << 17, 0, 10); + } + #[test] #[should_panic(expected = "fold_level")] fn count_min_sketch_fold_rejects_excessive_fold_level() { diff --git a/src/sketches/count_sketch_fold.rs b/src/sketches/count_sketch_fold.rs index 6c3b12cd..469dae9f 100644 --- a/src/sketches/count_sketch_fold.rs +++ b/src/sketches/count_sketch_fold.rs @@ -15,7 +15,7 @@ //! Count Sketch with W columns. use crate::common::structures::matrix_storage::RowEstimates; -use crate::count_min_fold::FoldCell; +use crate::count_min_fold::{FoldCell, MAX_FULL_COLS}; use crate::{DataInput, DefaultXxHasher, SketchHasher, TopKHeap, compute_median_inline_f64}; use std::marker::PhantomData; @@ -63,12 +63,17 @@ impl CountSketchFold { /// /// # Panics /// - /// Panics if `full_cols` is not a power of two or `fold_level` is too large. + /// Panics if `full_cols` is not a power of two, is above 65536, or + /// `fold_level` is too large. pub fn new(rows: usize, full_cols: usize, fold_level: u32, heap_size: usize) -> Self { assert!( full_cols.is_power_of_two(), "full_cols must be a power of two, got {full_cols}" ); + assert!( + full_cols <= MAX_FULL_COLS, + "full_cols must be at most {MAX_FULL_COLS}, got {full_cols}" + ); assert!( fold_level <= full_cols.trailing_zeros(), "fold_level {fold_level} too large for full_cols {full_cols}" @@ -141,6 +146,16 @@ impl CountSketchFold { &self.cells[row * self.fold_cols + fold_col] } + /// Bytes this sketch holds: the struct, the allocated cells, the entry + /// lists of collided cells and [`TopKHeap::memory_bytes`]. + pub fn memory_bytes(&self) -> usize { + let lists: usize = self.cells.iter().map(FoldCell::list_bytes).sum(); + size_of::() - size_of::() + + self.cells.capacity() * size_of::() + + lists + + self.heap.memory_bytes() + } + /// Total number of `(full_col, count)` entries across all cells. pub fn total_entries(&self) -> usize { self.cells.iter().map(|c| c.entry_count()).sum() @@ -206,6 +221,7 @@ impl CountSketchFold { let cell_value = self.cells[r * self.fold_cols + fc].get(full_col); estimates.push((sign * cell_value) as f64); } + crate::analytics::ops::add(self.rows); compute_median_inline_f64(&mut estimates) as i64 } @@ -245,6 +261,11 @@ impl CountSketchFold { let dst_row_off = r * target_fold_cols; for c in 0..self.fold_cols { let cell = &self.cells[src_row_off + c]; + let listed = match cell { + FoldCell::Collided(entries) => entries.len(), + _ => 0, + }; + crate::analytics::ops::add(1 + listed); for (full_col, count) in cell.iter() { let new_fc = (full_col as usize) & (target_fold_cols - 1); target.cells[dst_row_off + new_fc].add(full_col, count); @@ -272,7 +293,10 @@ impl CountSketchFold { fold_cols: new_fold_cols, full_cols: a.full_cols, fold_level: new_level, - cells: vec![FoldCell::Empty; a.rows * new_fold_cols], + cells: { + crate::analytics::ops::add(a.rows * new_fold_cols); + vec![FoldCell::Empty; a.rows * new_fold_cols] + }, heap: TopKHeap::new(heap_k), _hasher: PhantomData, }; @@ -322,7 +346,10 @@ impl CountSketchFold { fold_cols: new_fold_cols, full_cols: self.full_cols, fold_level: target_level, - cells: vec![FoldCell::Empty; self.rows * new_fold_cols], + cells: { + crate::analytics::ops::add(self.rows * new_fold_cols); + vec![FoldCell::Empty; self.rows * new_fold_cols] + }, heap: TopKHeap::new(self.heap.capacity()), _hasher: PhantomData, }; @@ -361,7 +388,10 @@ impl CountSketchFold { fold_cols: full_cols, full_cols, fold_level: 0, - cells: vec![FoldCell::Empty; rows * full_cols], + cells: { + crate::analytics::ops::add(rows * full_cols); + vec![FoldCell::Empty; rows * full_cols] + }, heap: TopKHeap::new(heap_k), _hasher: PhantomData, }; @@ -461,6 +491,12 @@ mod tests { let _: CountSketchFold = CountSketchFold::new(3, 1000, 0, 10); } + #[test] + #[should_panic(expected = "full_cols must be at most 65536")] + fn count_sketch_fold_rejects_more_columns_than_u16_holds() { + let _: CountSketchFold = CountSketchFold::new(3, 1 << 17, 0, 10); + } + #[test] #[should_panic(expected = "fold_level")] fn count_sketch_fold_rejects_excessive_fold_level() { diff --git a/src/sketches/countminsketch.rs b/src/sketches/countminsketch.rs index c8844ca5..408bcb86 100644 --- a/src/sketches/countminsketch.rs +++ b/src/sketches/countminsketch.rs @@ -227,8 +227,8 @@ impl CountMinSketch { impl CountMinSketch { /// Bytes a sketch of `rows * cols` counters holds: [`Self::memory_bytes`] /// of one built with those dimensions. Defined on the default hasher, so - /// [`CountMinF64::estimated_memory_bytes`] needs no type parameters. - pub fn estimated_memory_bytes(rows: usize, cols: usize) -> usize { + /// [`CountMinF64::theoretical_memory_bytes`] needs no type parameters. + pub fn theoretical_memory_bytes(rows: usize, cols: usize) -> usize { size_of::().saturating_add( rows.saturating_mul(cols) .saturating_mul(size_of::()), diff --git a/src/sketches/countminsketch_heap.rs b/src/sketches/countminsketch_heap.rs index 88613b29..5f8f5f76 100644 --- a/src/sketches/countminsketch_heap.rs +++ b/src/sketches/countminsketch_heap.rs @@ -56,8 +56,8 @@ where /// Bytes a sketch of `rows` x `cols` and `heap_size` holds once its heap is /// full of keys shaped like `key`, whether built by [`Self::new`], decoded, /// cloned or given a [`CountMinSketchWithHeapDelta`] through `merge_delta`: the struct, - /// the counters and [`TopKHeap::estimated_memory_bytes`]. - pub fn estimated_memory_bytes( + /// the counters and [`TopKHeap::theoretical_memory_bytes`]. + pub fn theoretical_memory_bytes( rows: usize, cols: usize, heap_size: usize, @@ -65,7 +65,7 @@ where ) -> usize { (size_of::() - size_of::>()) .saturating_add(rows.saturating_mul(cols).saturating_mul(size_of::())) - .saturating_add(TopKHeap::::estimated_memory_bytes( + .saturating_add(TopKHeap::::theoretical_memory_bytes( heap_size, key, )) } @@ -323,8 +323,10 @@ where for item in other.heap.heap() { candidate_keys.push(item.key.clone()); } + crate::analytics::ops::add(candidate_keys.len()); self.heap.clear(); for key in candidate_keys { + crate::analytics::ops::add(1); let key_ref = key.as_data_input(); let est = self.count_min_sketch.estimate(&key_ref); self.heap.update(&key_ref, est.into()); @@ -399,8 +401,10 @@ where for item in other.heap.heap() { candidate_keys.push(item.key.clone()); } + crate::analytics::ops::add(candidate_keys.len()); self.heap.clear(); for key in candidate_keys { + crate::analytics::ops::add(1); let key_ref = key.as_data_input(); let est = self.count_min_sketch.estimate(&key_ref); self.heap.update(&key_ref, est.into()); diff --git a/src/sketches/countminsketch_hll.rs b/src/sketches/countminsketch_hll.rs index 66d6d454..01d3a5b4 100644 --- a/src/sketches/countminsketch_hll.rs +++ b/src/sketches/countminsketch_hll.rs @@ -128,6 +128,12 @@ impl CountMinHll { } } + /// Bytes this sketch holds: the struct and its `rows * cols * 2^p` + /// one-byte registers. + pub fn memory_bytes(&self) -> usize { + size_of::() + self.buckets.as_slice().len() + } + /// Number of hash rows. pub fn rows(&self) -> usize { self.buckets.rows() @@ -176,6 +182,7 @@ impl CountMinHll { let col_hash = H::hash128_seeded(0, key); let hll_hash = H::hash64_seeded(HLL_SEED, distinct_value); let (index, rank) = self.register_and_rank_from_hash(hll_hash); + crate::analytics::ops::add(self.buckets.rows()); self.buckets.fast_insert( |registers, &(index, rank): &(usize, u8), _row| { registers[index] = registers[index].max(rank); @@ -201,6 +208,7 @@ impl CountMinHll { /// tightest available estimate. pub fn estimate(&self, key: &DataInput) -> f64 { let col_hash = H::hash128_seeded(0, key); + crate::analytics::ops::add(self.buckets.rows() << self.precision); self.buckets .fast_query_min(&col_hash, |registers, _row| classic_estimate(registers)) } @@ -223,6 +231,7 @@ impl CountMinHll { (rows, cols, precision) is {mine:?} against {theirs:?}" )); } + crate::analytics::ops::add(2 * self.buckets.as_slice().len()); for (reg, other_reg) in self .buckets .as_mut_slice() diff --git a/src/sketches/countminsketch_space_saving.rs b/src/sketches/countminsketch_space_saving.rs index 6aca99e0..8883dbc4 100644 --- a/src/sketches/countminsketch_space_saving.rs +++ b/src/sketches/countminsketch_space_saving.rs @@ -79,10 +79,12 @@ pub(crate) fn rank_candidates( .entries() .into_iter() .map(|(key, _, _)| { + crate::analytics::ops::add(2); let count = estimate(&key.as_data_input()); (key, count) }) .collect(); + crate::analytics::ops::add_sort(ranked.len()); ranked.sort_unstable_by(|a, b| b.1.order(&a.1).then_with(|| key_cmp(&a.0, &b.0))); ranked.truncate(k); ranked @@ -153,6 +155,14 @@ impl CountMinSketchWithSpaceSaving usize { + size_of::() - size_of::>() - size_of::>() + + self.count_min_sketch.memory_bytes() + + self.summary.memory_bytes() + } + /// Adds `other`'s matrix to this one and merges the summaries. Panics when /// the dimensions, the admission rates or the summary capacities differ. pub fn merge(&mut self, other: &Self) { diff --git a/src/sketches/countsketch.rs b/src/sketches/countsketch.rs index 9cf8a738..4f4ef529 100644 --- a/src/sketches/countsketch.rs +++ b/src/sketches/countsketch.rs @@ -244,7 +244,7 @@ impl CountSketch { /// Bytes a sketch of `rows * cols` counters holds: [`Self::memory_bytes`] /// of one built with those dimensions. Defined on the default hasher, so /// only the storage and path are named. - pub fn estimated_memory_bytes(rows: usize, cols: usize) -> usize { + pub fn theoretical_memory_bytes(rows: usize, cols: usize) -> usize { size_of::().saturating_add( rows.saturating_mul(cols) .saturating_mul(size_of::()), @@ -412,6 +412,8 @@ where if estimates.is_empty() { return 0.0; } + crate::analytics::ops::add(rows); + crate::analytics::ops::add_sort(rows); estimates.sort_unstable_by(f64::total_cmp); let mid = estimates.len() / 2; if estimates.len() % 2 == 1 { diff --git a/src/sketches/countsketch_heap.rs b/src/sketches/countsketch_heap.rs index 727a8e85..21b0087c 100644 --- a/src/sketches/countsketch_heap.rs +++ b/src/sketches/countsketch_heap.rs @@ -117,8 +117,8 @@ where /// Bytes a sketch of `rows` x `cols` and `heap_size` holds once its heap is /// full of keys shaped like `key`, whether built by [`Self::new`], decoded, /// cloned or given a [`CountSketchWithHeapDelta`] through `merge_delta`: the struct, - /// the counters and [`TopKHeap::estimated_memory_bytes`]. - pub fn estimated_memory_bytes( + /// the counters and [`TopKHeap::theoretical_memory_bytes`]. + pub fn theoretical_memory_bytes( rows: usize, cols: usize, heap_size: usize, @@ -126,7 +126,7 @@ where ) -> usize { (size_of::() - size_of::>()) .saturating_add(rows.saturating_mul(cols).saturating_mul(size_of::())) - .saturating_add(TopKHeap::::estimated_memory_bytes( + .saturating_add(TopKHeap::::theoretical_memory_bytes( heap_size, key, )) } @@ -378,8 +378,10 @@ where for item in other.heap.heap() { candidate_keys.push(item.key.clone()); } + crate::analytics::ops::add(candidate_keys.len()); self.heap.clear(); for key in candidate_keys { + crate::analytics::ops::add(1); let key_ref = key.as_data_input(); let est = self.count_sketch.estimate(&key_ref); self.heap.update(&key_ref, S::Counter::heap_count(est)); @@ -453,8 +455,10 @@ where for item in other.heap.heap() { candidate_keys.push(item.key.clone()); } + crate::analytics::ops::add(candidate_keys.len()); self.heap.clear(); for key in candidate_keys { + crate::analytics::ops::add(1); let key_ref = key.as_data_input(); let est = self.count_sketch.estimate(&key_ref); self.heap.update(&key_ref, S::Counter::heap_count(est)); diff --git a/src/sketches/countsketch_l2_norm.rs b/src/sketches/countsketch_l2_norm.rs index 9eea0ded..38ed0cc3 100644 --- a/src/sketches/countsketch_l2_norm.rs +++ b/src/sketches/countsketch_l2_norm.rs @@ -67,7 +67,7 @@ fn clamp_to_i64(accumulator: i128) -> i64 { impl CountSketchWithL2Norm { /// Bytes a sketch of `rows * cols` counters holds: [`Self::memory_bytes`] /// of one built with those dimensions, for every hasher. - pub fn estimated_memory_bytes(rows: usize, cols: usize) -> usize { + pub fn theoretical_memory_bytes(rows: usize, cols: usize) -> usize { size_of::().saturating_add( rows.saturating_mul(cols) .saturating_mul(size_of::()) @@ -179,6 +179,7 @@ impl CountSketchWithL2Norm { "seed mismatch while merging CountSketchWithL2Norm sketches" ); + crate::analytics::ops::add(self.rows * (3 * self.cols + 1)); for i in 0..self.rows { for j in 0..self.cols { self.counts[i][j] += other.counts[i][j]; @@ -218,6 +219,7 @@ impl CountSketchWithL2Norm { #[inline(always)] fn write_counter(&mut self, row: usize, col: usize, new_value: i64) { let old_value = self.counts.query_one_counter(row, col); + crate::analytics::ops::add(1); self.counts[row][col] = new_value; self.row_l2_squared[row] += capped_square(new_value) - capped_square(old_value); } @@ -252,6 +254,7 @@ impl CountSketchWithL2Norm { /// Returns the median estimate of the squared L2 norm across rows. pub fn l2_squared(&self) -> f64 { + crate::analytics::ops::add(2 * self.rows); let mut values: RowEstimates = self.row_l2_squared.as_slice()[..self.rows] .iter() .map(|&v| clamp_to_i64(v) as f64) @@ -292,6 +295,7 @@ impl CountSketchWithL2Norm { shift_amount += mask_bits; sign_bit_pos -= 1; } + crate::analytics::ops::add(self.rows); compute_median_inline_f64(&mut lst[..]) } } diff --git a/src/sketches/countsketch_space_saving.rs b/src/sketches/countsketch_space_saving.rs index 046eba7a..8bf5e566 100644 --- a/src/sketches/countsketch_space_saving.rs +++ b/src/sketches/countsketch_space_saving.rs @@ -76,6 +76,14 @@ where self.summary.capacity() } + /// Bytes this sketch holds: [`CountSketch::memory_bytes`] and + /// [`SpaceSaving::memory_bytes`]. + pub fn memory_bytes(&self) -> usize { + size_of::() - size_of::>() - size_of::>() + + self.count_sketch.memory_bytes() + + self.summary.memory_bytes() + } + /// Adds `other`'s matrix to this one and merges the summaries. Panics when /// the dimensions, the admission rates or the summary capacities differ. pub fn merge(&mut self, other: &Self) { diff --git a/src/sketches/ddsketch.rs b/src/sketches/ddsketch.rs index d63be802..8e8e1fb4 100644 --- a/src/sketches/ddsketch.rs +++ b/src/sketches/ddsketch.rs @@ -107,12 +107,14 @@ impl Buckets { let idx = idx_i32 as usize; let slice = self.counts.as_mut_slice(); if idx < slice.len() { + crate::analytics::ops::add(2); slice[idx] += 1; return; } } // This is the method that gets called only on rare expansions + crate::analytics::ops::add(3); self.ensure(k); let idx = (k - self.offset) as usize; self.counts.as_mut_slice()[idx] += 1; @@ -248,7 +250,9 @@ impl DdSketch { return Err("DDSketch value exceeds indexable range"); } self.count = self.count.checked_add(1).ok_or("DDSketch count overflow")?; + crate::analytics::ops::add(1); if v.abs() < min_indexable { + crate::analytics::ops::add(1); self.zero_count += 1; } else { let key = self.key_for(v.abs()); @@ -337,6 +341,7 @@ impl DdSketch { /// sample's magnitude is indexable. A nonzero magnitude below the /// indexable range is counted as zero and reads `0.0`. pub fn get_value_at_quantile(&self, q: f64) -> Option { + crate::analytics::ops::add(1); if self.count == 0 || q.is_nan() { return None; } @@ -369,6 +374,7 @@ impl DdSketch { if !(0.0..=1.0).contains(&q) { return Err(format!("DDSketch quantile {q} is outside [0, 1]")); } + crate::analytics::ops::add(1); if self.count == 0 { return Ok(None); } @@ -393,12 +399,16 @@ impl DdSketch { let mut last = 0.0; let mut seen = 0u64; let negative = self.negative_store.counts.as_slice(); + let offset = self.negative_store.offset; + // Each store's offset and the zero count. + crate::analytics::ops::add(3); for (i, &count) in negative.iter().enumerate().rev() { + crate::analytics::ops::add(1); if count == 0 { continue; } seen += count; - last = -self.bin_representative(self.negative_store.offset + i as i32); + last = -self.bin_representative(offset + i as i32); if seen > rank { return last; } @@ -411,12 +421,14 @@ impl DdSketch { } } let positive = self.positive_store.counts.as_slice(); + let offset = self.positive_store.offset; for (i, &count) in positive.iter().enumerate() { + crate::analytics::ops::add(1); if count == 0 { continue; } seen += count; - last = self.bin_representative(self.positive_store.offset + i as i32); + last = self.bin_representative(offset + i as i32); if seen > rank { return last; } @@ -454,7 +466,7 @@ impl DdSketch { /// the indexable one holds no buckets. /// /// Panics when `alpha` is outside `(0, 1)`, as [`Self::new`] does. - pub fn estimated_memory_bytes( + pub fn theoretical_memory_bytes( alpha: f64, n: usize, min_magnitude: f64, @@ -542,14 +554,21 @@ impl DdSketch { admission_rate::check_equal(self.admission_rate, other.admission_rate) .map_err(|e| format!("cannot merge DDSketches: {e}"))?; + crate::analytics::ops::add(2); if other.count == 0 { return Ok(()); } if self.count == 0 { + // Every slot, both offsets, the count and the zero count copied. + crate::analytics::ops::add( + 2 * (other.positive_store.counts.len() + other.negative_store.counts.len()) + 8, + ); *self = other.clone(); return Ok(()); } + // The count and the zero count added to. + crate::analytics::ops::add(3); self.count += other.count; // Merge bucket vectors @@ -585,10 +604,13 @@ impl DdSketch { return; } if store.is_empty() { + crate::analytics::ops::add(2 * other.counts.len() + 2); *store = other.clone(); return; } + // Both offsets read, every slot read and added in, the offset written. + crate::analytics::ops::add(3 + 2 * (store.counts.len() + other.counts.len())); let (self_l, self_r) = store.range().unwrap(); let (other_l, other_r) = other.range().unwrap(); diff --git a/src/sketches/elastic.rs b/src/sketches/elastic.rs index 951f7ebb..58745011 100644 --- a/src/sketches/elastic.rs +++ b/src/sketches/elastic.rs @@ -145,6 +145,15 @@ impl ElasticSketch { } } + /// Bytes this sketch holds: the struct, the heavy buckets with their flow + /// ids' owned bytes, and the light layer's counters. + pub fn memory_bytes(&self) -> usize { + let ids: usize = self.heavy.iter().map(|b| b.flow_id.capacity()).sum(); + size_of::() + self.heavy.capacity() * size_of::() + ids + - size_of::, RegularPath, H>>() + + self.light.memory_bytes() + } + /// Records one occurrence of `id`. /// /// A vacant bucket seats the flow; a matching bucket takes a positive vote. @@ -175,6 +184,7 @@ impl ElasticSketch { self.seat_over_stale_copy(idx, id, weight); return; } + crate::analytics::ops::add(1); let bucket = &mut self.heavy[idx]; if bucket.is_vacant() { @@ -282,6 +292,7 @@ impl ElasticSketch { /// layer whenever the bucket carries the eviction flag. pub fn estimate(&self, id: String) -> i32 { let idx = self.bucket_index(&id); + crate::analytics::ops::add(1); let bucket = &self.heavy[idx]; if !bucket.is_vacant() && bucket.flow_id == id { if bucket.eviction { @@ -353,10 +364,15 @@ impl ElasticSketch { let mut merged: Vec> = Vec::with_capacity(self.heavy.len()); for idx in 0..self.heavy.len() { - let mine = (!self.heavy[idx].is_vacant() && !self.stale_at(idx)) - .then(|| self.heavy[idx].clone()); - let theirs = (!other.heavy[idx].is_vacant() && !other.stale_at(idx)) - .then(|| other.heavy[idx].clone()); + crate::analytics::ops::add(3); + let mine = (!self.heavy[idx].is_vacant() && !self.stale_at(idx)).then(|| { + crate::analytics::ops::add(1); + self.heavy[idx].clone() + }); + let theirs = (!other.heavy[idx].is_vacant() && !other.stale_at(idx)).then(|| { + crate::analytics::ops::add(1); + other.heavy[idx].clone() + }); merged.push(match (mine, theirs) { (None, None) => None, @@ -374,6 +390,7 @@ impl ElasticSketch { } else { (theirs, mine) }; + crate::analytics::ops::add(1); losers.push((lost.flow_id, lost.vote_pos)); Some(kept) } @@ -381,6 +398,7 @@ impl ElasticSketch { } for (idx, slot) in merged.into_iter().enumerate() { + crate::analytics::ops::add(2); let bucket = &mut self.heavy[idx]; match slot { Some(mut kept) => { @@ -402,6 +420,7 @@ impl ElasticSketch { /// Adds each flow's votes to the light layer. fn spill(&mut self, flows: Vec<(String, i32)>) { for (flow_id, votes) in flows { + crate::analytics::ops::add(1); self.light .update_weighted(&DataInput::String(flow_id), votes); } @@ -430,13 +449,18 @@ impl ElasticSketch { /// left by an expansion are skipped, so no flow is reported twice. fn resident_flows(&self) -> Vec<(String, i32)> { let mut flows: Vec<(String, i32)> = (0..self.heavy.len()) - .filter(|idx| !self.heavy[*idx].is_vacant() && !self.stale_at(*idx)) + .filter(|idx| { + crate::analytics::ops::add(1); + !self.heavy[*idx].is_vacant() && !self.stale_at(*idx) + }) .map(|idx| { + crate::analytics::ops::add(2); let id = self.heavy[idx].flow_id.clone(); let size = self.estimate(id.clone()); (id, size) }) .collect(); + crate::analytics::ops::add_sort(flows.len()); flows.sort_unstable(); flows } @@ -458,6 +482,7 @@ impl ElasticSketch { /// [`Self::heavy_hitters`]), by estimate descending, ties by flow id. pub fn top_k(&self, k: usize) -> Vec<(String, i32)> { let mut flows = self.resident_flows(); + crate::analytics::ops::add_sort(flows.len()); flows.sort_by_key(|flow| std::cmp::Reverse(flow.1)); flows.truncate(k); flows @@ -606,6 +631,7 @@ impl ElasticSketch { if !self.stale_copies { return false; } + crate::analytics::ops::add(1); let bucket = &self.heavy[idx]; !bucket.is_vacant() && self.bucket_index(&bucket.flow_id) != idx } diff --git a/src/sketches/hll.rs b/src/sketches/hll.rs index e725579f..598910ba 100644 --- a/src/sketches/hll.rs +++ b/src/sketches/hll.rs @@ -216,6 +216,7 @@ impl let leading_zero = ((hashed_val << Registers::PRECISION) + Registers::INDEX_MASK) .leading_zeros() as u8 + 1; + crate::analytics::ops::add(1); let registers = self.registers.as_mut_slice(); if leading_zero > registers[bucket_num] { registers[bucket_num] = leading_zero; @@ -241,6 +242,7 @@ impl self.admission_rate, other.admission_rate, "admission rate mismatch while merging HyperLogLog sketches" ); + crate::analytics::ops::add(2 * Registers::NUM_REGISTERS); for (reg, other_val) in self .registers .as_mut_slice() @@ -263,7 +265,7 @@ impl /// called through; the crate builds 12, 14 and 16, and /// [`impl_hll_registers!`](crate::impl_hll_registers) others. Saturates at /// `usize::MAX`. - pub fn estimated_memory_bytes(precision: usize) -> usize { + pub fn theoretical_memory_bytes(precision: usize) -> usize { size_of::().saturating_add(register_bytes(precision)) } } @@ -314,6 +316,7 @@ impl HyperLogLogImpl usize { + crate::analytics::ops::add(Registers::NUM_REGISTERS); classic_estimate(self.registers.as_slice()) as usize } } @@ -379,6 +382,7 @@ impl HyperLogLogImpl [u32; ERTL_HISTOGRAM_CAP] { + crate::analytics::ops::add(2 * Registers::NUM_REGISTERS); let mut histogram = [0; ERTL_HISTOGRAM_CAP]; for ®ister in self.registers.as_slice() { histogram[register as usize] += 1; @@ -389,6 +393,7 @@ impl HyperLogLogImpl usize { let histogram = self.get_histogram(); + crate::analytics::ops::add(Registers::RANK_HASH_BITS + 2); let m: f64 = Registers::NUM_REGISTERS as f64; let mut z = m * self.hll_ertl_tau((m - histogram[Registers::RANK_HASH_BITS + 1] as f64) / m); @@ -441,10 +446,12 @@ impl HyperLogLogHipImpl { let leading_zero = ((hashed_val << Registers::PRECISION) + Registers::INDEX_MASK) .leading_zeros() as u8 + 1; + crate::analytics::ops::add(1); let registers = self.registers.as_mut_slice(); let old_value = registers[bucket_num]; let new_value = leading_zero; if new_value > old_value { + crate::analytics::ops::add(5); registers[bucket_num] = leading_zero; self.est += Registers::NUM_REGISTERS as f64 / (self.kxq0 + self.kxq1); if old_value < 32 { @@ -470,6 +477,7 @@ impl HyperLogLogHipImpl { /// Returns the estimated cardinality from the HIP running estimate. pub fn estimate(&self) -> usize { + crate::analytics::ops::add(1); self.est as usize } @@ -483,7 +491,7 @@ impl HyperLogLogHipImpl { /// called through; the crate builds 12, 14 and 16, and /// [`impl_hll_registers!`](crate::impl_hll_registers) others. Saturates at /// `usize::MAX`. - pub fn estimated_memory_bytes(precision: usize) -> usize { + pub fn theoretical_memory_bytes(precision: usize) -> usize { size_of::().saturating_add(register_bytes(precision)) } } diff --git a/src/sketches/kll.rs b/src/sketches/kll.rs index 85a28ca7..a7e08d1b 100644 --- a/src/sketches/kll.rs +++ b/src/sketches/kll.rs @@ -25,14 +25,14 @@ pub(crate) use wire::{ validate_kll_payload, }; -const MAX_LEVELS: usize = 61; +pub(crate) const MAX_LEVELS: usize = 61; -const CAPACITY_CACHE_LEN: usize = 20; +pub(crate) const CAPACITY_CACHE_LEN: usize = 20; pub(crate) const MAX_K: usize = 26_602; /// Minimum level capacity `m` that `with_k` and `with_k_and_seed` use. pub const DEFAULT_M: usize = 8; const CAPACITY_DECAY: f64 = 2.0 / 3.0; -const DEFAULT_K: i32 = 200; +pub(crate) const DEFAULT_K: i32 = 200; /// Items the buffer holds at its first allocation; it then doubles, up to /// the sketch's `max_capacity`. const INITIAL_BUFFER_ITEMS: usize = 16; @@ -133,14 +133,14 @@ fn compute_max_capacity(k: usize, m: usize) -> usize { } /// `k` and `m` as a sketch keeps them: `m` in `[2, MAX_K]`, `k` in `[m, MAX_K]`. -fn normalized_k_m(k: usize, m: usize) -> (usize, usize) { +pub(crate) fn normalized_k_m(k: usize, m: usize) -> (usize, usize) { let m = m.clamp(2, MAX_K); (k.clamp(m, MAX_K), m) } /// Level capacities by height from the top: `max(ceil(k * (2/3)^i), m)`, /// the last entry standing for every greater height. -fn capacity_table(k: usize, m: usize) -> [u32; CAPACITY_CACHE_LEN] { +pub(crate) fn capacity_table(k: usize, m: usize) -> [u32; CAPACITY_CACHE_LEN] { let mut table = [0; CAPACITY_CACHE_LEN]; let mut scale = 1.0_f64; for cap in table.iter_mut() { @@ -150,6 +150,21 @@ fn capacity_table(k: usize, m: usize) -> [u32; CAPACITY_CACHE_LEN] { table } +/// `L(n)`: the levels a sketch of `k` holds after `n` items, a level added +/// once the top holds `k + 1` items of weight 2^top. +pub(crate) fn levels_after(k: usize, n: usize) -> usize { + let mut num_levels = 1; + while num_levels < MAX_LEVELS + && 1usize + .checked_shl(num_levels as u32 - 1) + .and_then(|weight| weight.checked_mul(k + 1)) + .is_some_and(|items| items <= n) + { + num_levels += 1; + } + num_levels +} + /// Checked total weighted item count for compactor-level sizes given /// **bottom-first** (`sizes[h]` is the item count at compactor level `h`, whose /// weight is `2^h`). Returns `None` on `usize` overflow. @@ -181,6 +196,7 @@ pub(crate) fn randomly_halve_up( ) -> usize { let num_survivors = (pop - offset).div_ceil(2); let dest = begin + pop - num_survivors; + crate::analytics::ops::add(2 * num_survivors); for d in (0..num_survivors).rev() { items[dest + d] = items[begin + offset + 2 * d]; } @@ -200,12 +216,15 @@ pub(crate) fn merge_sorted_runs( if left_len == 0 || left_len >= total { return; } + crate::analytics::ops::add(2); if slice[left_len - 1].total_cmp(&slice[left_len]).is_le() { return; } let right_len = total - left_len; buf.clear(); + // Copying the shorter run out, then one read and one write per placed item. + crate::analytics::ops::add(2 * left_len.min(right_len) + 1); if left_len <= right_len { buf.extend_from_slice(&slice[..left_len]); @@ -213,6 +232,7 @@ pub(crate) fn merge_sorted_runs( let mut j = left_len; let mut k = 0; while i < buf.len() && j < total { + crate::analytics::ops::add(2); if buf[i].total_cmp(&slice[j]).is_le() { slice[k] = buf[i]; i += 1; @@ -223,6 +243,7 @@ pub(crate) fn merge_sorted_runs( k += 1; } if i < buf.len() { + crate::analytics::ops::add(2 * (buf.len() - i)); slice[k..k + (buf.len() - i)].copy_from_slice(&buf[i..]); } } else { @@ -232,6 +253,7 @@ pub(crate) fn merge_sorted_runs( let mut k = total; while i > 0 && j > 0 { k -= 1; + crate::analytics::ops::add(2); if buf[j - 1].total_cmp(&slice[i - 1]).is_ge() { slice[k] = buf[j - 1]; j -= 1; @@ -241,6 +263,7 @@ pub(crate) fn merge_sorted_runs( } } if j > 0 { + crate::analytics::ops::add(2 * j); slice[..j].copy_from_slice(&buf[..j]); } } @@ -389,19 +412,10 @@ impl Kll { /// /// The struct and level array are exact; the buffer, compaction scratch /// space and CDF are bounded by the items `n` can leave retained. - pub fn estimated_memory_bytes(k: usize, m: usize, n: usize) -> usize { + pub fn theoretical_memory_bytes(k: usize, m: usize, n: usize) -> usize { let (k, m) = normalized_k_m(k, m); let capacities = capacity_table(k, m); - // A level is added once the top holds `k + 1` items of weight 2^top. - let mut num_levels = 1; - while num_levels < MAX_LEVELS - && 1usize - .checked_shl(num_levels as u32 - 1) - .and_then(|weight| weight.checked_mul(k + 1)) - .is_some_and(|items| items <= n) - { - num_levels += 1; - } + let num_levels = levels_after(k, n); let level_capacity: usize = (0..num_levels) .map(|height| capacities[height.min(CAPACITY_CACHE_LEN - 1)] as usize) .sum(); @@ -503,6 +517,8 @@ impl Kll { } self.levels[0] -= 1; self.items[self.levels[0]] = value; + // levels[0] read and decremented, the item written, levels[1] read. + crate::analytics::ops::add(3); if self.levels[1] - self.levels[0] > self.level0_capacity { self.compress_while_updating(); @@ -543,6 +559,7 @@ impl Kll { fn compress_while_updating(&mut self) { let mut h = 0; loop { + crate::analytics::ops::add(2); let pop = self.level_size(h); let cap = self.capacity_for_level(h); if pop <= cap { @@ -564,8 +581,11 @@ impl Kll { let beg = self.levels[h]; let end = self.levels[h + 1]; let pop = end - beg; + // The level's bounds and the end of the level above read. + crate::analytics::ops::add(3); if h == 0 { + crate::analytics::ops::add_sort(pop); self.items[beg..end].sort_unstable_by(T::total_cmp); } @@ -586,6 +606,7 @@ impl Kll { if delta > 0 && h > 0 { let lo = self.levels[0]; let hi = beg; + crate::analytics::ops::add(1 + 2 * (hi - lo) + h); if hi > lo { self.items.copy_within(lo..hi, lo + delta); } @@ -596,12 +617,14 @@ impl Kll { self.levels[h] = surv_start; self.levels[h + 1] = surv_start; + crate::analytics::ops::add(2); } fn add_new_top_level(&mut self) { let sentinel = self.levels[self.num_levels]; self.num_levels += 1; self.levels[self.num_levels] = sentinel; + crate::analytics::ops::add(2); self.top_height = self.num_levels - 1; self.level0_capacity = self.capacity_for_level(0); } @@ -646,6 +669,7 @@ impl Kll { let start = self.levels[h]; let end = self.levels[h + 1]; let weight = 1 << h; + crate::analytics::ops::add(2 + 2 * (end - start)); for &value in &self.items[start..end] { cdf.entries.push(CdfEntry { value: value.to_f64(), @@ -659,6 +683,8 @@ impl Kll { return cdf; } + crate::analytics::ops::add_sort(cdf.entries.len()); + crate::analytics::ops::add(cdf.entries.len()); // Stability is irrelevant: entries sharing a value contribute the same // weight either way, so the unstable sort's reordering cannot change // any prefix sum. `total_cmp` also avoids `partial_cmp`'s NaN branch. @@ -719,6 +745,7 @@ impl Kll { if let Err(problem) = self.check_mergeable(other) { panic!("{problem}"); } + crate::analytics::ops::add(2); let other_start = other.levels[0]; let other_end = other.levels[other.num_levels]; if other_end == other_start { @@ -735,6 +762,7 @@ impl Kll { for h in 0..self.num_levels { let s = self.levels[h]; let e = self.levels[h + 1]; + crate::analytics::ops::add(2 + 2 * (e - s)); work[h].extend_from_slice(&self.items[s..e]); } let mut merge_buf: Vec = Vec::new(); @@ -742,6 +770,7 @@ impl Kll { for h in 0..other.num_levels { let s = other.levels[h]; let e = other.levels[h + 1]; + crate::analytics::ops::add(2 + 2 * (e - s)); let self_len = work[h].len(); work[h].extend_from_slice(&other.items[s..e]); // Levels >= 1 are individually sorted in both operands already; @@ -763,6 +792,7 @@ impl Kll { let mut h = 0; while h < self.num_levels { if h == 0 { + crate::analytics::ops::add_sort(work[0].len()); work[0].sort_unstable_by(T::total_cmp); } while work[h].len() > self.capacity_for_level(h) { @@ -775,6 +805,8 @@ impl Kll { let offset = usize::from(self.coin.toss()); let num_survivors = randomly_halve_up(work[h].as_mut_slice(), 0, pop, offset); let discard = pop - num_survivors; + // The survivors move to the front, and level h+1 is appended. + crate::analytics::ops::add(2 * num_survivors + 2 * work[h + 1].len()); work[h].drain(0..discard); // Promote the (sorted) survivors into level h+1, merging @@ -796,6 +828,7 @@ impl Kll { self.grow_buffer(total); } let mc = self.items.len(); + crate::analytics::ops::add(2 * total + self.levels.len()); let mut cursor = mc; for h in (0..self.num_levels).rev() { let len = work[h].len(); @@ -833,6 +866,7 @@ impl Kll { let start = self.levels[h]; let end = self.levels[h + 1]; let weight = 1 << h; + crate::analytics::ops::add(2 + end - start); for &val in &self.items[start..end] { if val.to_f64() <= x { r += weight; @@ -849,6 +883,7 @@ impl Kll { /// Returns the total (weighted) number of items ingested by the sketch. pub fn count(&self) -> usize { + crate::analytics::ops::add(2 * self.num_levels); let mut total = 0; for h in 0..self.num_levels { total += self.level_size(h) * (1 << h); @@ -1051,7 +1086,10 @@ impl Cdf { return 0.0; } let slice = self.entries.as_slice(); + // One read per probe, and one for the entry returned. + crate::analytics::ops::add(1); match slice.binary_search_by(|e| { + crate::analytics::ops::add(1); e.quantile .partial_cmp(&p) .unwrap_or(std::cmp::Ordering::Less) diff --git a/src/sketches/kll_dynamic.rs b/src/sketches/kll_dynamic.rs index 59e35834..7a6518b8 100644 --- a/src/sketches/kll_dynamic.rs +++ b/src/sketches/kll_dynamic.rs @@ -127,6 +127,8 @@ impl KllDynamic { let levels_slice = self.level_bounds_top_down.as_slice(); let l0_start = levels_slice[self.num_levels - 1]; let l0_count = self.items.len() - l0_start; + // The item and the last bound written, the bottom level's start read. + crate::analytics::ops::add(3); if l0_count > self.level0_capacity { self.compress_while_needed(); @@ -152,6 +154,7 @@ impl KllDynamic { loop { let level_idx = self.num_levels - 1 - h; let cap = self.capacity_for_level(h); + crate::analytics::ops::add(2); let size = self.level_size(h); @@ -198,6 +201,8 @@ impl KllDynamic { } fn add_new_top_level(&mut self) { + // Every bound shifts one slot, then the new first and the last are written. + crate::analytics::ops::add(2 * self.level_bounds_top_down.len() + 2); self.level_bounds_top_down.insert(0, 0); if let Some(last) = self.level_bounds_top_down.last_mut() { *last = self.items.len(); @@ -218,6 +223,8 @@ impl KllDynamic { let items = self.items.as_mut_slice(); + crate::analytics::ops::add(2); + crate::analytics::ops::add_sort(count); items[start..end].sort_unstable_by(T::total_cmp); let offset = usize::from(self.coin.toss()); @@ -225,6 +232,7 @@ impl KllDynamic { let mut i = offset; while i < count { + crate::analytics::ops::add(2); items[start + survivors] = items[start + i]; survivors += 1; i += 2; @@ -234,6 +242,7 @@ impl KllDynamic { let start_garbage = start + survivors; let end_garbage = end; let tail_len = items.len() - end_garbage; + crate::analytics::ops::add(2 * tail_len); if tail_len > 0 { // Safety: source and destination ranges may overlap, but `ptr::copy` handles overlap. @@ -248,6 +257,7 @@ impl KllDynamic { self.items.truncate(new_len); // Update level pointers after shift + crate::analytics::ops::add(2 + self.num_levels - cur_lvl_idx); let levels_slice = self.level_bounds_top_down.as_mut_slice(); levels_slice[cur_lvl_idx] = start + survivors; @@ -324,6 +334,7 @@ impl KllDynamic { let start = levels[idx]; let end = levels[idx + 1]; let weight = 1 << h; + crate::analytics::ops::add(2 + 2 * (end - start)); for &value in &items[start..end] { cdf.entries.push(DynamicCdfEntry { value: value.to_f64(), @@ -337,6 +348,8 @@ impl KllDynamic { return cdf; } + crate::analytics::ops::add_sort(cdf.entries.len()); + crate::analytics::ops::add(cdf.entries.len()); cdf.entries .as_mut_slice() .sort_unstable_by(|a, b| a.value.total_cmp(&b.value)); @@ -399,6 +412,8 @@ impl KllDynamic { let idx = self.num_levels - 1 - h; let levels = self.level_bounds_top_down.as_slice(); let (s, e) = (levels[idx], levels[idx + 1]); + crate::analytics::ops::add(2 + 2 * (e - s)); + crate::analytics::ops::add_sort(e - s); work[h].extend_from_slice(&self.items.as_slice()[s..e]); work[h].sort_unstable_by(T::total_cmp); } @@ -408,6 +423,8 @@ impl KllDynamic { let idx = other.num_levels - 1 - h; let levels = other.level_bounds_top_down.as_slice(); let (s, e) = (levels[idx], levels[idx + 1]); + crate::analytics::ops::add(2 + 2 * (e - s)); + crate::analytics::ops::add_sort(e - s); let self_len = work[h].len(); work[h].extend_from_slice(&other.items.as_slice()[s..e]); work[h][self_len..].sort_unstable_by(T::total_cmp); @@ -436,6 +453,8 @@ impl KllDynamic { let offset = usize::from(self.coin.toss()); let num_survivors = randomly_halve_up(work[h].as_mut_slice(), 0, pop, offset); let discard = pop - num_survivors; + // The survivors move to the front, and level h+1 is appended. + crate::analytics::ops::add(2 * num_survivors + 2 * work[h + 1].len()); work[h].drain(0..discard); // Promote the (sorted) survivors into level h+1, merging @@ -453,6 +472,7 @@ impl KllDynamic { // top level first, matching this type's storage order (array // index 0 == top level, growing toward level 0 at the tail). let total: usize = work[..self.num_levels].iter().map(Vec::len).sum(); + crate::analytics::ops::add(2 * total + self.num_levels + 1); let mut items = Vec::with_capacity(total); let mut levels = Vec::with_capacity(self.num_levels + 1); levels.push(0usize); @@ -481,6 +501,7 @@ impl KllDynamic { let start = levels[idx]; let end = levels[idx + 1]; let weight = 1 << h; + crate::analytics::ops::add(2 + end - start); for &val in &items[start..end] { if val.to_f64() <= x { @@ -556,7 +577,10 @@ impl DynamicCdf { return 0.0; } let slice = self.entries.as_slice(); + // One read per probe, and one for the entry returned. + crate::analytics::ops::add(1); match slice.binary_search_by(|e| { + crate::analytics::ops::add(1); e.normalized_rank .partial_cmp(&p) .unwrap_or(std::cmp::Ordering::Less) diff --git a/src/sketches/kmv.rs b/src/sketches/kmv.rs index bbedb06f..3eccf001 100644 --- a/src/sketches/kmv.rs +++ b/src/sketches/kmv.rs @@ -16,7 +16,7 @@ use std::marker::PhantomData; mod wire; // expect error bound to be less than 2% -const KMV_DEFAULT_K: usize = 4096_usize; +pub(crate) const KMV_DEFAULT_K: usize = 4096_usize; #[derive(Clone, Debug)] pub struct Kmv { @@ -48,18 +48,17 @@ impl Kmv { pub fn update_with_hash(&mut self, hash_value: u64) { // A full sketch keeps only hashes below its largest retained one. if self.retained_hashes.is_full() - && self - .retained_hashes - .peek() - .is_some_and(|max| hash_value >= *max) + && self.retained_hashes.peek().is_some_and(|max| { + crate::analytics::ops::add(1); + hash_value >= *max + }) { return; } - if self - .retained_hashes - .iter() - .any(|value| *value == hash_value) - { + if self.retained_hashes.iter().any(|value| { + crate::analytics::ops::add(1); + *value == hash_value + }) { return; } self.retained_hashes.push(hash_value); @@ -71,6 +70,7 @@ impl Kmv { if self.retained_hashes.len() < self.k { return self.retained_hashes.len() as f64; } + crate::analytics::ops::add(1); let largest = *self .retained_hashes .peek() @@ -86,6 +86,7 @@ impl Kmv { "Two KMV sketch have different k size, not mergeable" ); for &value in other.retained_hashes.iter() { + crate::analytics::ops::add(1); self.update_with_hash(value); } } @@ -104,7 +105,7 @@ impl Kmv { /// distinct inputs, merges included: [`Self::memory_bytes`] exactly, and an /// upper bound when fewer arrive. The heap reserves `min(k, 1024)` slots /// and doubles toward `k`. Saturates at `usize::MAX`. - pub fn estimated_memory_bytes(k: usize, distinct: usize) -> usize { + pub fn theoretical_memory_bytes(k: usize, distinct: usize) -> usize { let slots = vec_slots_after_pushes( k.min(PREALLOCATED_SLOTS), k.min(distinct), diff --git a/src/sketches/microscope.rs b/src/sketches/microscope.rs index 2a130bac..fb003818 100644 --- a/src/sketches/microscope.rs +++ b/src/sketches/microscope.rs @@ -337,6 +337,11 @@ impl MicroCm { } } + /// Bytes this sketch holds: the struct and its cells. + pub fn memory_bytes(&self) -> usize { + size_of::() + self.cells.as_slice().len() + } + /// Number of hash rows. pub fn rows(&self) -> usize { self.cells.rows() diff --git a/src/sketches/microscope/cell.rs b/src/sketches/microscope/cell.rs index ba10e166..3c75588f 100644 --- a/src/sketches/microscope/cell.rs +++ b/src/sketches/microscope/cell.rs @@ -214,6 +214,7 @@ impl MicroLayout { /// A cell's zoom exponent. #[inline(always)] pub fn zoom(cell: &[u8], layout: &MicroLayout) -> u8 { + crate::analytics::ops::add(1); cell[layout.zoom_at] } @@ -223,12 +224,14 @@ pub fn zoom(cell: &[u8], layout: &MicroLayout) -> u8 { /// so the record needs no alignment guarantee beyond being a byte slice. #[inline(always)] pub fn shutter(cell: &[u8], layout: &MicroLayout) -> u32 { + crate::analytics::ops::add(1); let at = layout.shutter_at; u32::from_le_bytes([cell[at], cell[at + 1], cell[at + 2], cell[at + 3]]) } #[inline(always)] fn set_shutter(cell: &mut [u8], layout: &MicroLayout, value: u32) { + crate::analytics::ops::add(1); let at = layout.shutter_at; cell[at..at + 4].copy_from_slice(&value.to_le_bytes()); } @@ -237,6 +240,7 @@ fn set_shutter(cell: &mut [u8], layout: &MicroLayout, value: u32) { /// units — that is, the pixel scaled by `c^Z`. #[inline(always)] pub fn scaled_pixel(cell: &[u8], layout: &MicroLayout, n: u64) -> f64 { + crate::analytics::ops::add(2); let unit = (layout.params.c as f64).powi(cell[layout.zoom_at] as i32); cell[layout.pixel_at(n)] as f64 * unit } @@ -329,14 +333,16 @@ pub fn unbiased_div_u64(value: u64, divisor: u64, rounding: &Rounding) -> u64 { /// At the zoom cap the pixel saturates rather than wrapping, and the unit is /// left alone. fn credit_pixel(cell: &mut [u8], layout: &MicroLayout, at: usize, rounding: &Rounding) { + crate::analytics::ops::add(1); if cell[at] < PIXEL_MAX { cell[at] += 1; return; } - let z = cell[layout.zoom_at]; + let z = zoom(cell, layout); if z >= layout.max_zoom() { return; } + crate::analytics::ops::add(1 + layout.pixels); cell[layout.zoom_at] = z + 1; let c = layout.params.c; for (index, slot) in cell.iter_mut().take(layout.pixels).enumerate() { @@ -366,7 +372,7 @@ pub fn insert(cell: &mut [u8], layout: &MicroLayout, n: u64, rounding: &Rounding let c = layout.params.c as u64; let mut pending = shutter(cell, layout) as u64 + 1; loop { - let unit = c.pow(cell[layout.zoom_at] as u32); + let unit = c.pow(zoom(cell, layout) as u32); if pending < unit { break; } @@ -410,6 +416,7 @@ pub fn enter_sub_window( return; } if crossed >= layout.pixels as u64 { + crate::analytics::ops::add(layout.pixels); for slot in cell.iter_mut().take(layout.pixels) { *slot = 0; } @@ -417,7 +424,7 @@ pub fn enter_sub_window( } else { // 1. Close out the shutter into the sub-window that just ended. let ended = new_n - crossed; - let unit = (layout.params.c as u64).pow(cell[layout.zoom_at] as u32); + let unit = (layout.params.c as u64).pow(zoom(cell, layout) as u32); let pending = shutter(cell, layout) as u64; set_shutter(cell, layout, 0); if pending > 0 { @@ -429,6 +436,7 @@ pub fn enter_sub_window( } // 2. Clear each slot entered on the way, oldest of them first. + crate::analytics::ops::add(crossed as usize); for step in (0..crossed).rev() { cell[layout.pixel_at(new_n - step)] = 0; } @@ -450,7 +458,7 @@ pub fn enter_sub_window( /// or above the new unit, and the next insert would then credit a whole /// pixel for less than a unit of pending items. pub fn try_zoom_in(cell: &mut [u8], layout: &MicroLayout) -> bool { - let z = cell[layout.zoom_at]; + let z = zoom(cell, layout); if z == 0 { return false; } @@ -459,9 +467,13 @@ pub fn try_zoom_in(cell: &mut [u8], layout: &MicroLayout) -> bool { // to zero, which would have made the test vacuously true for every cell // and left a zoomed-out cell unable to ever zoom back in. let ceiling = layout.zoom_in_ceiling; - if cell.iter().take(layout.pixels).any(|&p| p as u32 > ceiling) { + if cell.iter().take(layout.pixels).any(|&p| { + crate::analytics::ops::add(1); + p as u32 > ceiling + }) { return false; } + crate::analytics::ops::add(layout.pixels + 1); let c = layout.params.c; for slot in cell.iter_mut().take(layout.pixels) { *slot = (*slot as u32 * c) as u8; @@ -504,6 +516,7 @@ pub fn merge_cells(dst: &mut [u8], src: &[u8], layout: &MicroLayout, n: u64, rou let z_src = zoom(src, layout); let mut target = z_dst.max(z_src); + crate::analytics::ops::add(3 * layout.pixels + 1); let mut sums: Vec = (0..layout.pixels) .map(|i| { scale_down(dst[i] as u32, target - z_dst, c, rounding) @@ -519,7 +532,11 @@ pub fn merge_cells(dst: &mut [u8], src: &[u8], layout: &MicroLayout, n: u64, rou let remainder = combined % unit; let cap = layout.max_zoom(); - while sums.iter().any(|&v| v > PIXEL_MAX as u32) { + while sums.iter().any(|&v| { + crate::analytics::ops::add(1); + v > PIXEL_MAX as u32 + }) { + crate::analytics::ops::add(layout.pixels); if target >= cap { for value in sums.iter_mut() { *value = (*value).min(PIXEL_MAX as u32); @@ -534,6 +551,7 @@ pub fn merge_cells(dst: &mut [u8], src: &[u8], layout: &MicroLayout, n: u64, rou // too and needs no adjustment. } + crate::analytics::ops::add(2 * layout.pixels + 1); for (slot, value) in dst.iter_mut().take(layout.pixels).zip(sums) { *slot = value as u8; } diff --git a/src/sketches/set_aggregator.rs b/src/sketches/set_aggregator.rs index e8e23518..df690450 100644 --- a/src/sketches/set_aggregator.rs +++ b/src/sketches/set_aggregator.rs @@ -3,6 +3,7 @@ //! `SetAggregator` is not a sketch — it is a plain `HashSet` with an //! ASAPv1 form (§3.21); [`SetAggregatorDelta`] is its delta (§3.22). +use crate::common::heap::hash_map_bytes; use std::collections::HashSet; mod delta; @@ -25,15 +26,30 @@ impl SetAggregator { /// Insert a key into the set. pub fn update(&mut self, key: &str) { + crate::analytics::ops::add(1); self.values.insert(key.to_string()); } + /// True when `key` is in the set. + pub fn contains(&self, key: &str) -> bool { + crate::analytics::ops::add(1); + self.values.contains(key) + } + + /// Bytes this set holds: the struct, the hash table and each key's bytes. + pub fn memory_bytes(&self) -> usize { + size_of::() + + hash_map_bytes::(self.values.capacity()) + + self.values.iter().map(String::capacity).sum::() + } + /// Merge another SetAggregator into self in place (set union). pub fn merge( &mut self, other: &SetAggregator, ) -> Result<(), Box> { for v in &other.values { + crate::analytics::ops::add(2); self.values.insert(v.clone()); } Ok(()) diff --git a/src/sketches/space_saving.rs b/src/sketches/space_saving.rs index ade6c8f9..814ac2d7 100644 --- a/src/sketches/space_saving.rs +++ b/src/sketches/space_saving.rs @@ -27,6 +27,7 @@ //! ICDT 2005. use crate::common::DigestBuildHasher; +use crate::common::heap::{hash_index_bytes, hash_map_bytes, vec_slots_after_pushes}; use crate::{DataInput, DefaultXxHasher, OwnedDataInput, SketchHasher}; use smallvec::SmallVec; use std::collections::HashMap; @@ -132,7 +133,10 @@ impl SpaceSaving { /// nothing. #[inline(always)] pub fn min_count(&self) -> u64 { - let lowest = if self.monitored.len() == self.capacity && self.bucket_head != NIL { + let full = self.monitored.len() == self.capacity; + crate::analytics::ops::add(1 + full as usize); + let lowest = if full && self.bucket_head != NIL { + crate::analytics::ops::add(2); self.buckets[self.bucket_head].count } else { 0 @@ -140,8 +144,52 @@ impl SpaceSaving { self.discarded_max.max(lowest) } + /// Bytes this summary holds: the struct, the allocated key, bucket and + /// free-list slots, the key index and the keys' owned bytes. + pub fn memory_bytes(&self) -> usize { + let keys: usize = self + .monitored + .iter() + .map(|c| match &c.key { + OwnedDataInput::String(s) => s.capacity(), + OwnedDataInput::Bytes(b) => b.capacity(), + _ => 0, + }) + .sum(); + let spilled: usize = self + .index + .values() + .filter(|slot| slot.spilled()) + .map(|slot| slot.capacity() * size_of::()) + .sum(); + size_of::() + + self.monitored.capacity() * size_of::() + + self.buckets.capacity() * size_of::() + + self.bucket_free.capacity() * size_of::() + + hash_map_bytes::<(u64, Slot)>(self.index.capacity()) + + keys + + spilled + } + + /// Upper bound on [`Self::memory_bytes`] for a summary of `capacity` + /// counters whose keys own `key_bytes` bytes each, merges included. + /// + /// At most `capacity + 1` buckets exist at once, in vectors that grow by + /// doubling; the index follows std's `HashMap` layout under churn. + pub fn theoretical_memory_bytes(capacity: usize, key_bytes: usize) -> usize { + let m = capacity.max(1); + let buckets = vec_slots_after_pushes(0, m + 1, size_of::()); + let free = vec_slots_after_pushes(0, m + 1, size_of::()); + size_of::() + .saturating_add(m.saturating_mul(size_of::().saturating_add(key_bytes))) + .saturating_add(buckets.saturating_mul(size_of::())) + .saturating_add(free.saturating_mul(size_of::())) + .saturating_add(hash_index_bytes::<(u64, Slot)>(m, m)) + } + /// Drops every counter. pub fn clear(&mut self) { + crate::analytics::ops::add(3); self.monitored.clear(); self.buckets.clear(); self.bucket_free.clear(); @@ -175,6 +223,7 @@ impl SpaceSaving { } if self.monitored.len() < self.capacity { + crate::analytics::ops::add(2); let seated = self.discarded_max.saturating_add(weight); let discarded_max = self.discarded_max; self.seat(digest, value.to_owned_input(), seated, discarded_max); @@ -189,6 +238,7 @@ impl SpaceSaving { self.discarded_max ); self.discarded_max = lowest; + crate::analytics::ops::add(10); self.unindex(self.monitored[victim].digest, victim); self.monitored[victim].key = value.to_owned_input(); self.monitored[victim].digest = digest; @@ -213,7 +263,10 @@ impl SpaceSaving { pub fn estimate(&self, value: &DataInput) -> u64 { let digest = H::hash64_seeded(0, value); match self.find(digest, value) { - Some(cid) => self.buckets[self.monitored[cid].bucket].count, + Some(cid) => { + crate::analytics::ops::add(2); + self.buckets[self.monitored[cid].bucket].count + } None => 0, } } @@ -259,11 +312,14 @@ impl SpaceSaving { /// `(key, count, error)`. pub fn top_k(&self, k: usize) -> Vec<(OwnedDataInput, u64, u64)> { let mut out = Vec::with_capacity(k.min(self.monitored.len())); + crate::analytics::ops::add(1); let mut bid = self.bucket_tail; while bid != NIL && out.len() < k { + crate::analytics::ops::add(3); let count = self.buckets[bid].count; let mut cid = self.buckets[bid].head; while cid != NIL && out.len() < k { + crate::analytics::ops::add(4); out.push(( self.monitored[cid].key.clone(), count, @@ -278,6 +334,7 @@ impl SpaceSaving { /// Every monitored key as `(key, count, error)`, in no particular order. pub fn entries(&self) -> Vec<(OwnedDataInput, u64, u64)> { + crate::analytics::ops::add(3 * self.monitored.len()); self.monitored .iter() .map(|c| (c.key.clone(), self.buckets[c.bucket].count, c.error)) @@ -301,6 +358,7 @@ impl SpaceSaving { let mut merged: HashMap, DigestBuildHasher> = HashMap::default(); + crate::analytics::ops::add(6 * self.monitored.len()); for c in &self.monitored { merged.entry(c.digest).or_default().push(MergeEntry { key: c.key.clone(), @@ -313,29 +371,42 @@ impl SpaceSaving { for c in &other.monitored { let count = other.buckets[c.bucket].count; let slot = merged.entry(c.digest).or_default(); - match slot.iter_mut().find(|entry| entry.key == c.key) { + crate::analytics::ops::add(3); + match slot.iter_mut().find(|entry| { + crate::analytics::ops::add(1); + entry.key == c.key + }) { Some(entry) => { + crate::analytics::ops::add(4); entry.count = entry.count.saturating_add(count); entry.error = entry.error.saturating_add(c.error); entry.paired = true; } - None => slot.push(MergeEntry { - key: c.key.clone(), - digest: c.digest, - count: count.saturating_add(mine_min), - error: c.error.saturating_add(mine_min), - paired: true, - }), + None => { + crate::analytics::ops::add(3); + slot.push(MergeEntry { + key: c.key.clone(), + digest: c.digest, + count: count.saturating_add(mine_min), + error: c.error.saturating_add(mine_min), + paired: true, + }) + } } } + crate::analytics::ops::add(merged.len()); let mut flat: Vec = merged.into_values().flatten().collect(); + crate::analytics::ops::add(2 * flat.len()); for entry in &mut flat { + crate::analytics::ops::add(1); if !entry.paired { + crate::analytics::ops::add(2); entry.count = entry.count.saturating_add(theirs_min); entry.error = entry.error.saturating_add(theirs_min); } } + crate::analytics::ops::add_sort(flat.len()); flat.sort_unstable_by(|a, b| { b.count .cmp(&a.count) @@ -344,12 +415,14 @@ impl SpaceSaving { let mut discarded_max = mine_min.saturating_add(theirs_min); if flat.len() > self.capacity { + crate::analytics::ops::add(1); discarded_max = discarded_max.max(flat[self.capacity].count); flat.truncate(self.capacity); } self.clear(); self.discarded_max = discarded_max; + crate::analytics::ops::add(1 + flat.len()); for entry in flat { self.seat(entry.digest, entry.key, entry.count, entry.error); } @@ -358,23 +431,29 @@ impl SpaceSaving { // -- Stream-Summary internals ------------------------------------------- fn find(&self, digest: u64, value: &DataInput) -> Option { + crate::analytics::ops::add(1); self.index.get(&digest).and_then(|ids| { - ids.iter() - .copied() - .find(|cid| self.monitored[*cid].key == *value) + ids.iter().copied().find(|cid| { + crate::analytics::ops::add(1); + self.monitored[*cid].key == *value + }) }) } fn find_key(&self, digest: u64, key: &OwnedDataInput) -> Option { + crate::analytics::ops::add(1); self.index.get(&digest).and_then(|ids| { - ids.iter() - .copied() - .find(|cid| self.monitored[*cid].key == *key) + ids.iter().copied().find(|cid| { + crate::analytics::ops::add(1); + self.monitored[*cid].key == *key + }) }) } fn unindex(&mut self, digest: u64, cid: usize) { + crate::analytics::ops::add(1); if let Some(ids) = self.index.get_mut(&digest) { + crate::analytics::ops::add(ids.len()); ids.retain(|id| *id != cid); if ids.is_empty() { self.index.remove(&digest); @@ -385,6 +464,7 @@ impl SpaceSaving { /// Takes a free counter for `key` at `count` with `error`. fn seat(&mut self, digest: u64, key: OwnedDataInput, count: u64, error: u64) { let cid = self.monitored.len(); + crate::analytics::ops::add(7); self.monitored.push(MonitoredKey { key, digest, @@ -400,6 +480,7 @@ impl SpaceSaving { /// Moves `cid` up by `count`, creating the destination bucket if needed. fn raise(&mut self, cid: usize, count: u64) { + crate::analytics::ops::add(3); let from = self.monitored[cid].bucket; let target_count = self.buckets[from].count.saturating_add(count); if target_count == self.buckets[from].count { @@ -414,16 +495,23 @@ impl SpaceSaving { /// such bucket exists. `after` of `NIL` searches from the low end, and any /// other `after` holds a count below `count`. fn bucket_for(&mut self, after: usize, count: u64) -> usize { + crate::analytics::ops::add(1); let mut prev = after; let mut next = if after == NIL { self.bucket_head } else { self.buckets[after].next }; - while next != NIL && self.buckets[next].count < count { + while next != NIL { + crate::analytics::ops::add(1); + if self.buckets[next].count >= count { + break; + } + crate::analytics::ops::add(1); prev = next; next = self.buckets[next].next; } + crate::analytics::ops::add((next != NIL) as usize); if next != NIL && self.buckets[next].count == count { return next; } @@ -437,8 +525,10 @@ impl SpaceSaving { prev, next, }; + crate::analytics::ops::add(6); let bid = match self.bucket_free.pop() { Some(id) => { + crate::analytics::ops::add(1); self.buckets[id] = bucket; id } @@ -461,6 +551,7 @@ impl SpaceSaving { } fn attach(&mut self, cid: usize, bid: usize) { + crate::analytics::ops::add(5 + (self.buckets[bid].head != NIL) as usize); let head = self.buckets[bid].head; self.monitored[cid].prev = NIL; self.monitored[cid].next = head; @@ -475,6 +566,7 @@ impl SpaceSaving { let bid = self.monitored[cid].bucket; let prev = self.monitored[cid].prev; let next = self.monitored[cid].next; + crate::analytics::ops::add(8 + (next != NIL) as usize); if prev != NIL { self.monitored[prev].next = next; } else { @@ -496,6 +588,7 @@ impl SpaceSaving { fn drop_bucket(&mut self, bid: usize) { let prev = self.buckets[bid].prev; let next = self.buckets[bid].next; + crate::analytics::ops::add(8); if prev != NIL { self.buckets[prev].next = next; } else { diff --git a/src/sketches/uniform.rs b/src/sketches/uniform.rs index bb12c4db..fda0f71f 100644 --- a/src/sketches/uniform.rs +++ b/src/sketches/uniform.rs @@ -13,6 +13,9 @@ struct SampleEntry { value: f64, } +/// Bytes one sample slot holds. +pub(crate) const SAMPLE_ENTRY_BYTES: usize = size_of::(); + impl SampleEntry { fn new(priority: u64, value: f64) -> Self { Self { priority, value } @@ -62,7 +65,13 @@ impl UniformSampling { self.total_seen } + /// Bytes this sampler holds: the struct and its entry slots. + pub fn memory_bytes(&self) -> usize { + size_of::() + self.entries.capacity() * size_of::() + } + pub fn samples(&self) -> Vec { + crate::analytics::ops::add(2 * self.entries.len()); self.entries.iter().map(|entry| entry.value).collect() } @@ -102,6 +111,7 @@ impl UniformSampling { pub fn update(&mut self, value: f64) { self.total_seen = self.total_seen.saturating_add(1); + crate::analytics::ops::add(1); let target_size = Self::target_size(self.total_seen, self.sample_rate); let priority = self.next_random(); self.insert_entry(SampleEntry::new(priority, value)); @@ -113,6 +123,10 @@ impl UniformSampling { return Err("Cannot merge uniform samplers with different sampling rates"); } let combined_seen = self.total_seen.saturating_add(other.total_seen); + let both = self.entries.len() + other.entries.len(); + // Both counts read, every entry copied and sorted, the count written. + crate::analytics::ops::add(3 + 2 * both); + crate::analytics::ops::add_sort(both); let mut merged = Vec::with_capacity(self.entries.len() + other.entries.len()); merged.extend(self.entries.iter().cloned()); merged.extend(other.entries.iter().cloned()); @@ -134,12 +148,14 @@ impl UniformSampling { } fn insert_entry(&mut self, entry: SampleEntry) { - let idx = match self - .entries - .binary_search_by(|probe| probe.priority.cmp(&entry.priority)) - { + let idx = match self.entries.binary_search_by(|probe| { + crate::analytics::ops::add(1); + probe.priority.cmp(&entry.priority) + }) { Ok(position) | Err(position) => position, }; + // Each later entry shifts one slot, and the new one is written. + crate::analytics::ops::add(2 * (self.entries.len() - idx) + 1); self.entries.insert(idx, entry); } diff --git a/tests/analytics/cardinality.rs b/tests/analytics/cardinality.rs new file mode 100644 index 00000000..bef989af --- /dev/null +++ b/tests/analytics/cardinality.rs @@ -0,0 +1,757 @@ +//! HyperLogLog, HyperLogLogHip, Kmv, SetAggregator and Bloom. + +use crate::support::*; +use asap_sketchlib::analytics::{ + BloomParams, DistinctData, HllData, HllParams, KmvParams, SetData, +}; +use asap_sketchlib::sketches::hll::{HyperLogLogHipImpl, HyperLogLogImpl}; +use asap_sketchlib::{ + Bloom, Classic, DataInput, ErrorBound, ErrorMetric, Ertl, FastPath, HllRegistersP12, + HllRegistersP14, Kmv, RegularPath, SetAggregator, SketchAnalytics, StatisticKind, +}; +use rand::Rng; + +asap_sketchlib::impl_hll_registers!(HllRegistersP8, 8, 1_usize << 8); + +type ClassicP8 = HyperLogLogImpl; +type ClassicP12 = HyperLogLogImpl; +type ClassicP14 = HyperLogLogImpl; +type ErtlP8 = HyperLogLogImpl; +type ErtlP12 = HyperLogLogImpl; +type HipP8 = HyperLogLogHipImpl; +type HipP12 = HyperLogLogHipImpl; +type BloomRegular = Bloom; +type BloomFast = Bloom; +type KmvX = Kmv; + +const BLOOM_SIZES: [BloomParams; 2] = [ + BloomParams { + rows: 3, + cols: 1024, + }, + BloomParams { + rows: 7, + cols: 1 << 16, + }, +]; + +fn key(id: u64) -> DataInput<'static> { + DataInput::U64(id) +} + +fn distinct(d: u64) -> DistinctData { + DistinctData { distinct: Some(d) } +} + +/// `count` fresh random keys. +fn fresh_keys(rng: &mut rand::rngs::StdRng, count: usize) -> Vec { + (0..count).map(|_| rng.random()).collect() +} + +/// `sqrt(mean(((estimate − truth)/truth)²))` over `trials` fresh key sets of +/// `truth` keys each. +fn relative_rmse(salt: u64, trials: u64, truth: usize, estimate: impl Fn(&[u64]) -> f64) -> f64 { + let sum: f64 = (0..trials) + .map(|trial| { + let keys = fresh_keys(&mut rng(salt, trial), truth); + ((estimate(&keys) - truth as f64) / truth as f64).powi(2) + }) + .sum(); + (sum / trials as f64).sqrt() +} + +#[test] +fn hll_memory_is_exact() { + let none = HllData::default(); + let p8 = HllParams { precision: 8 }; + let p14 = HllParams { precision: 14 }; + assert_eq!( + ::theoretical_memory_bytes(&p8, &none), + Some(ClassicP8::new().memory_bytes()) + ); + assert_eq!( + ::theoretical_memory_bytes(&HllParams { precision: 12 }, &none), + Some(ErtlP12::new().memory_bytes()) + ); + assert_eq!( + ::theoretical_memory_bytes(&p14, &none), + Some(ClassicP14::new().memory_bytes()) + ); + assert_eq!( + ::theoretical_memory_bytes(&p8, &()), + Some(HipP8::new().memory_bytes()) + ); +} + +#[test] +fn kmv_memory_is_exact_for_known_distinct_and_bounds_unknown() { + for k in [64, 3000] { + for d in [10u64, 2000, 20_000] { + let mut sketch: Kmv = Kmv::new(k); + for id in 0..d { + sketch.update(&key(id)); + } + let p = KmvParams { k }; + assert_eq!( + ::theoretical_memory_bytes(&p, &distinct(d)), + Some(sketch.memory_bytes()), + "k={k} D={d}" + ); + let unknown = + ::theoretical_memory_bytes(&p, &DistinctData::default()); + assert!(unknown.unwrap() >= sketch.memory_bytes()); + } + } +} + +#[test] +fn set_aggregator_memory_is_exact_and_needs_both_inputs() { + for (d, len) in [(0u64, 8usize), (5, 8), (1000, 12), (20_000, 6)] { + let mut set = SetAggregator::new(); + let mut other = SetAggregator::new(); + for id in 0..d { + let k = format!("{id:0len$}"); + if id % 2 == 0 { + set.update(&k); + } else { + other.update(&k); + } + } + set.merge(&other).unwrap(); + let data = SetData { + distinct: Some(d), + mean_key_bytes: Some(len as f64), + }; + assert_eq!( + SetAggregator::theoretical_memory_bytes(&(), &data), + Some(set.memory_bytes()), + "D={d}" + ); + for unknown in [ + SetData { + mean_key_bytes: None, + ..data + }, + SetData { + distinct: None, + ..data + }, + ] { + assert_eq!(SetAggregator::theoretical_memory_bytes(&(), &unknown), None); + } + } +} + +#[test] +fn bloom_memory_is_exact() { + for p in BLOOM_SIZES + .into_iter() + .chain([BloomParams { rows: 2, cols: 100 }]) + { + let none = DistinctData::default(); + assert_eq!( + ::theoretical_memory_bytes(&p, &none), + Some(BloomRegular::with_dimensions(p.rows, p.cols).memory_bytes()) + ); + assert_eq!( + ::theoretical_memory_bytes(&p, &none), + Some(BloomFast::with_dimensions(p.rows, p.cols).memory_bytes()) + ); + } +} + +/// The standard error is a root-mean-square relative error, so the measured +/// one over many trials lands within 10% of `ε`. +#[test] +fn hll_standard_errors_match_the_measured_relative_rmse() { + let p = HllParams { precision: 8 }; + let truth = 10 << 8; + let trials = 2000; + let check = |name: &str, bound: ErrorBound, measured: f64| { + assert_eq!(bound.metric, ErrorMetric::Relative); + assert_eq!(bound.delta, None); + assert!( + (measured / bound.epsilon - 1.0).abs() <= 0.1, + "{name}: measured relative RMSE {measured}, standard error {}", + bound.epsilon + ); + }; + let none = HllData::default(); + check( + "classic", + ClassicP8::theoretical_error(StatisticKind::Distinct, &p, &none, None).unwrap(), + relative_rmse(10, trials, truth, |keys| { + let mut sketch = ClassicP8::new(); + keys.iter().for_each(|&id| sketch.update(&key(id))); + sketch.estimate() as f64 + }), + ); + check( + "ertl", + ErtlP8::theoretical_error(StatisticKind::Distinct, &p, &none, None).unwrap(), + relative_rmse(11, trials, truth, |keys| { + let mut sketch = ErtlP8::new(); + keys.iter().for_each(|&id| sketch.update(&key(id))); + sketch.estimate() as f64 + }), + ); + check( + "hip", + HipP8::theoretical_error(StatisticKind::Distinct, &p, &(), None).unwrap(), + relative_rmse(12, trials, truth, |keys| { + let mut sketch = HipP8::new(); + keys.iter().for_each(|&id| sketch.update(&key(id))); + sketch.estimate() as f64 + }), + ); +} + +#[test] +fn classic_linear_counting_is_within_epsilon_with_probability_one_minus_delta() { + let p = HllParams { precision: 12 }; + let max_distinct = 16u64; + let data = HllData { + max_distinct: Some(max_distinct), + }; + let delta = 0.05; + let bound = + ClassicP12::theoretical_error(StatisticKind::Distinct, &p, &data, Some(delta)).unwrap(); + assert_eq!( + bound.contract, + "classic_hll_linear_counting_collision_bound_v1" + ); + assert_eq!(bound.delta, Some(delta)); + let trials = 1000; + let mut failures = 0; + for trial in 0..trials { + let keys = fresh_keys(&mut rng(13, trial), max_distinct as usize); + let mut sketch = ClassicP12::new(); + keys.iter().for_each(|&id| sketch.update(&key(id))); + let truth = max_distinct as f64; + if (sketch.estimate() as f64 - truth).abs() > bound.epsilon * truth { + failures += 1; + } + } + assert!( + failures <= allowed_failures(trials as usize, delta), + "{failures}/{trials} beyond eps={} at delta={delta}", + bound.epsilon + ); +} + +#[test] +fn hll_contracts_follow_the_variant_and_the_conditions() { + let p = HllParams { precision: 12 }; + let small = HllData { + max_distinct: Some(8), + }; + // At D = 256 the collision bound applies but its ε exceeds 1.04/64. + let medium = HllData { + max_distinct: Some(256), + }; + let large = HllData { + max_distinct: Some(4096), + }; + let generic = "generic_hll_rse_only_no_confidence_v1"; + let rse = |b: Option| b.map(|b| (b.contract, b.delta)); + assert_eq!( + rse(ClassicP12::theoretical_error( + StatisticKind::Distinct, + &p, + &HllData::default(), + None + )), + Some((generic, None)) + ); + for data in [&medium, &large] { + assert_eq!( + rse(ClassicP12::theoretical_error( + StatisticKind::Distinct, + &p, + data, + None + )), + Some((generic, None)) + ); + } + assert_eq!( + rse(ErtlP12::theoretical_error( + StatisticKind::Distinct, + &p, + &small, + None + )), + Some((generic, None)) + ); + let collision = + ClassicP12::theoretical_error(StatisticKind::Distinct, &p, &small, None).unwrap(); + assert_eq!(collision.delta, Some(0.01)); + assert_eq!( + HipP12::theoretical_error(StatisticKind::Distinct, &p, &(), None) + .unwrap() + .contract, + "hip_hll_rse_only_no_confidence_v1" + ); + assert_eq!(HipP12::theoretical_merge_ops(&p, &()), None); +} + +#[test] +fn hll_standard_error_sizing_meets_the_target_and_one_step_smaller_does_not() { + let none = HllData::default(); + for epsilon in [0.1, 0.02, 0.005] { + let p = ErtlP12::params_for(StatisticKind::Distinct, epsilon, 0.01, &none).unwrap(); + let smaller = HllParams { + precision: p.precision - 1, + }; + let error = |p| { + ErtlP12::theoretical_error(StatisticKind::Distinct, &p, &none, None) + .unwrap() + .epsilon + }; + assert!(error(p) <= epsilon && error(smaller) > epsilon, "{p:?}"); + assert_eq!( + ClassicP12::params_for(StatisticKind::Distinct, epsilon, 0.01, &none), + Some(p) + ); + let hip = HipP12::params_for(StatisticKind::Distinct, epsilon, 0.01, &()).unwrap(); + let hip_error = |p| { + HipP12::theoretical_error(StatisticKind::Distinct, &p, &(), None) + .unwrap() + .epsilon + }; + let smaller = HllParams { + precision: hip.precision - 1, + }; + assert!(hip_error(hip) <= epsilon && hip_error(smaller) > epsilon); + } +} + +#[test] +fn classic_collision_sizing_meets_the_target_and_one_step_smaller_does_not() { + for (max_distinct, epsilon, delta) in + [(100u64, 0.001, 0.05), (20, 0.001, 0.01), (9, 0.005, 0.01)] + { + let data = HllData { + max_distinct: Some(max_distinct), + }; + let p = ClassicP12::params_for(StatisticKind::Distinct, epsilon, delta, &data).unwrap(); + let bound = + ClassicP12::theoretical_error(StatisticKind::Distinct, &p, &data, Some(delta)).unwrap(); + assert_eq!( + bound.contract, + "classic_hll_linear_counting_collision_bound_v1" + ); + assert!(bound.epsilon <= epsilon, "{p:?} gives {bound:?}"); + let smaller = HllParams { + precision: p.precision - 1, + }; + assert!( + ClassicP12::theoretical_error(StatisticKind::Distinct, &smaller, &data, Some(delta)) + .is_none_or(|b| b.contract != bound.contract || b.epsilon > epsilon), + "{p:?} is larger than needed" + ); + } +} + +/// `params_for` returns a precision whose own bound meets `ε`, and `δ` when +/// the bound has one. +#[test] +fn classic_sizing_meets_its_own_bound() { + for (max_distinct, epsilon, delta) in [ + (1000u64, 0.01, 0.01), + (1000, 0.05, 0.01), + (256, 0.02, 0.01), + (100, 0.001, 0.05), + (5000, 0.05, 1e-3), + ] { + let data = HllData { + max_distinct: Some(max_distinct), + }; + let p = ClassicP12::params_for(StatisticKind::Distinct, epsilon, delta, &data).unwrap(); + let bound = + ClassicP12::theoretical_error(StatisticKind::Distinct, &p, &data, Some(delta)).unwrap(); + assert!( + bound.epsilon <= epsilon && bound.delta.is_none_or(|d| d <= delta), + "D={max_distinct} ({epsilon}, {delta}): {p:?} gives {bound:?}" + ); + } +} + +#[test] +fn hll_standard_error_uses_flajolet_constants_below_256_registers() { + let none = HllData::default(); + let error = |precision| { + ErtlP12::theoretical_error( + StatisticKind::Distinct, + &HllParams { precision }, + &none, + None, + ) + }; + assert_eq!(error(3), None); + for (precision, beta) in [(4, 1.106), (5, 1.070), (6, 1.054), (7, 1.046), (8, 1.04)] { + let m = (1u64 << precision) as f64; + assert!((error(precision).unwrap().epsilon - beta / m.sqrt()).abs() < 1e-12); + } + let sized = |epsilon| { + ErtlP12::params_for(StatisticKind::Distinct, epsilon, 0.01, &none) + .unwrap() + .precision + }; + assert_eq!(sized(0.5), 4); + // 1.106/4 > 0.27 ≥ 1.04/4. + assert_eq!(sized(0.27), 5); + assert_eq!( + ClassicP12::params_for(StatisticKind::Distinct, 0.27, 0.01, &none), + Some(HllParams { precision: 5 }) + ); +} + +#[test] +fn kmv_is_exact_below_k_and_within_epsilon_with_probability_one_minus_delta() { + let k = 256; + let p = KmvParams { k }; + let delta = 0.05; + let exact = KmvX::theoretical_error(StatisticKind::Distinct, &p, &distinct(200), None).unwrap(); + assert_eq!((exact.epsilon, exact.delta), (0.0, Some(0.0))); + let bound = + KmvX::theoretical_error(StatisticKind::Distinct, &p, &distinct(5000), Some(delta)).unwrap(); + assert_eq!(bound.metric, ErrorMetric::Relative); + let trials = 500; + let mut failures = 0; + for trial in 0..trials { + let mut rng = rng(14, trial); + for (d, epsilon) in [(200usize, 0.0), (5000, bound.epsilon)] { + let keys = fresh_keys(&mut rng, d); + let mut sketch: Kmv = Kmv::new(k); + for &id in &keys { + sketch.update(&key(id)); + sketch.update(&key(id)); + } + let error = (sketch.estimate() - d as f64).abs(); + if epsilon == 0.0 { + assert_eq!(error, 0.0, "below k the estimate is the count"); + } else if error > epsilon * d as f64 { + failures += 1; + } + } + } + assert!( + failures <= allowed_failures(trials as usize, delta), + "{failures}/{trials} beyond eps={} at delta={delta}", + bound.epsilon + ); +} + +#[test] +fn kmv_error_needs_a_valid_delta() { + let p = KmvParams { k: 256 }; + let error = |data: &DistinctData, delta| { + KmvX::theoretical_error(StatisticKind::Distinct, &p, data, Some(delta)) + }; + for delta in [f64::NAN, -0.1, 0.0] { + assert_eq!(error(&distinct(5000), delta), None, "{delta}"); + } + for delta in [f64::NAN, -0.1] { + assert_eq!(error(&distinct(200), delta), None, "{delta}"); + } + assert_eq!(error(&distinct(200), 0.0).unwrap().epsilon, 0.0); + assert!(error(&distinct(5000), 0.01).is_some()); +} + +#[test] +fn kmv_sizing_meets_the_target_and_one_smaller_k_does_not() { + let unknown = DistinctData::default(); + let p = KmvX::params_for(StatisticKind::Distinct, 0.05, 0.01, &unknown).unwrap(); + assert_eq!(p.k, 4323); + for (epsilon, delta) in [(0.05, 0.01), (0.2, 0.1), (0.01, 1e-4), (1.5, 0.01)] { + let p = KmvX::params_for(StatisticKind::Distinct, epsilon, delta, &unknown).unwrap(); + let bound = + KmvX::theoretical_error(StatisticKind::Distinct, &p, &unknown, Some(delta)).unwrap(); + assert!(bound.epsilon <= epsilon, "{p:?} gives {bound:?}"); + if p.k > 2 { + let smaller = KmvParams { k: p.k - 1 }; + let bound = + KmvX::theoretical_error(StatisticKind::Distinct, &smaller, &unknown, Some(delta)) + .unwrap(); + assert!(bound.epsilon > epsilon, "{p:?} is larger than needed"); + } + } + let p = KmvX::params_for(StatisticKind::Distinct, 0.01, 1e-4, &distinct(500)).unwrap(); + assert_eq!(p.k, 501); +} + +#[test] +fn set_aggregator_is_exact() { + for statistic in [StatisticKind::Distinct, StatisticKind::Membership] { + assert_eq!( + SetAggregator::theoretical_error(statistic, &(), &SetData::default(), None), + Some(ErrorBound::EXACT) + ); + } + let mut rng = rng(15, 0); + let keys: Vec = fresh_keys(&mut rng, 500) + .iter() + .map(|id| id.to_string()) + .collect(); + let mut set = SetAggregator::new(); + for k in keys.iter().chain(keys.iter().take(100)) { + set.update(k); + } + assert_eq!(set.values.len(), 500); + assert!(keys.iter().all(|k| set.contains(k))); + assert!(!set.contains("not a key")); +} + +/// Members always read present, and a fresh key reads present at the +/// predicted rate. +#[test] +fn bloom_false_positive_rate_is_at_most_delta() { + let p = BloomParams { + rows: 4, + cols: 1024, + }; + let d = 650u64; + let bound = + BloomRegular::theoretical_error(StatisticKind::Membership, &p, &distinct(d), None).unwrap(); + assert_eq!((bound.metric, bound.epsilon), (ErrorMetric::ABSOLUTE, 0.0)); + let delta = bound.delta.unwrap(); + assert!((0.03..0.08).contains(&delta), "{delta}"); + let trials = 2000; + for fast in [false, true] { + let mut failures = 0; + for trial in 0..trials { + let mut rng = rng(16 + fast as u64, trial); + let keys = fresh_keys(&mut rng, d as usize + 1); + let (members, probe) = keys.split_at(d as usize); + let mut regular = BloomRegular::with_dimensions(p.rows, p.cols); + let mut fastpath = BloomFast::with_dimensions(p.rows, p.cols); + let contains = |regular: &BloomRegular, fastpath: &BloomFast, id| { + if fast { + fastpath.contains(&key(id)) + } else { + regular.contains(&key(id)) + } + }; + for &id in members { + if fast { + fastpath.update(&key(id)); + } else { + regular.update(&key(id)); + } + } + assert!( + members.iter().all(|&id| contains(®ular, &fastpath, id)), + "fast={fast}: a member read absent" + ); + if contains(®ular, &fastpath, probe[0]) { + failures += 1; + } + } + assert!( + failures <= allowed_failures(trials as usize, delta), + "fast={fast}: {failures}/{trials} false positives at delta={delta}" + ); + } +} + +#[test] +fn bloom_error_needs_d_and_a_power_of_two_width() { + let p = BloomParams { + rows: 4, + cols: 1000, + }; + assert!( + BloomRegular::theoretical_error(StatisticKind::Membership, &p, &distinct(10), None) + .is_none() + ); + let p = BloomParams { + rows: 4, + cols: 1024, + }; + assert!( + BloomRegular::theoretical_error( + StatisticKind::Membership, + &p, + &DistinctData::default(), + None + ) + .is_none() + ); + let rate = BloomRegular::theoretical_error(StatisticKind::Membership, &p, &distinct(10), None) + .unwrap() + .delta + .unwrap(); + assert!( + BloomRegular::theoretical_error( + StatisticKind::Membership, + &p, + &distinct(10), + Some(rate / 2.0) + ) + .is_none() + ); +} + +#[test] +fn bloom_sizing_meets_delta_with_the_fewest_bits() { + for (d, delta) in [(1000u64, 0.01), (100_000, 1e-4), (10, 0.1), (5000, 1e-6)] { + let data = distinct(d); + let p = BloomRegular::params_for(StatisticKind::Membership, 0.0, delta, &data).unwrap(); + let meets = |rows: usize, cols: usize| { + BloomRegular::theoretical_error( + StatisticKind::Membership, + &BloomParams { rows, cols }, + &data, + Some(delta), + ) + .is_some() + }; + assert!(meets(p.rows, p.cols), "{p:?}"); + assert!( + p.cols == 1 || !meets(p.rows, p.cols / 2), + "{p:?} is wider than needed" + ); + for rows in 1..=asap_sketchlib::BLOOM_MAX_SLICES { + let mut cols = 1usize; + while rows * cols < p.rows * p.cols { + assert!(!meets(rows, cols), "{rows}x{cols} beats {p:?}"); + cols *= 2; + } + } + } +} + +#[cfg(feature = "op-count")] +mod ops { + use super::*; + + #[test] + fn hll_classic_and_ertl() { + let none = HllData::default(); + let p8 = HllParams { precision: 8 }; + let p12 = HllParams { precision: 12 }; + macro_rules! check { + ($ty:ty, $p:expr) => {{ + let mut a = <$ty>::new(); + let mut b = <$ty>::new(); + for id in 0..5000 { + a.update(&key(id)); + b.update(&key(id + 2500)); + } + let update = <$ty>::theoretical_update_ops(&$p, &none); + for id in 0..100 { + assert_ops_within("update", update, || a.update(&key(id * 7919))); + } + let query = + <$ty>::theoretical_query_ops(StatisticKind::Distinct, &$p, &none).unwrap(); + assert_ops_within("query", query, || a.estimate()); + let merge = <$ty>::theoretical_merge_ops(&$p, &none).unwrap(); + assert_ops_within("merge", merge, || a.merge(&b)); + }}; + } + check!(ClassicP8, p8); + check!(ClassicP12, p12); + check!(ErtlP8, p8); + check!(ErtlP12, p12); + } + + #[test] + fn hll_hip() { + macro_rules! check { + ($ty:ty, $p:expr) => {{ + let update = <$ty>::theoretical_update_ops(&$p, &()); + let mut sketch = <$ty>::new(); + for id in 0..5000 { + assert_ops_within("update", update, || sketch.update(&key(id))); + } + let query = + <$ty>::theoretical_query_ops(StatisticKind::Distinct, &$p, &()).unwrap(); + assert_ops_within("query", query, || sketch.estimate()); + }}; + } + check!(HipP8, HllParams { precision: 8 }); + check!(HipP12, HllParams { precision: 12 }); + } + + #[test] + fn kmv() { + for k in [64usize, 1000] { + let p = KmvParams { k }; + let data = DistinctData::default(); + let update = KmvX::theoretical_update_ops(&p, &data); + let mut filling: Kmv = Kmv::new(k); + for id in 0..k as u64 { + assert_ops_within("update, filling", update, || { + filling.update_with_hash(u64::MAX - id) + }); + } + let mut a: Kmv = Kmv::new(k); + let mut b: Kmv = Kmv::new(k); + for id in 0..20_000 { + a.update(&key(id)); + b.update(&key(id + 1_000_000)); + } + for hash in [0, 1, 2, u64::MAX] { + assert_ops_within("update, full", update, || a.update_with_hash(hash)); + } + let query = KmvX::theoretical_query_ops(StatisticKind::Distinct, &p, &data).unwrap(); + assert_ops_within("query", query, || a.estimate()); + let merge = KmvX::theoretical_merge_ops(&p, &data).unwrap(); + assert_ops_within("merge", merge, || a.merge(&b)); + let mut empty: Kmv = Kmv::new(k); + assert_ops_within("merge into empty", merge, || empty.merge(&b)); + } + } + + #[test] + fn set_aggregator() { + for d in [10u64, 1000] { + let data = SetData { + distinct: Some(d), + mean_key_bytes: None, + }; + let mut a = SetAggregator::new(); + let mut b = SetAggregator::new(); + for id in 0..d { + a.update(&id.to_string()); + b.update(&(id + d / 2).to_string()); + } + let update = SetAggregator::theoretical_update_ops(&(), &data); + assert_ops_within("update", update, || a.update("new")); + let distinct = + SetAggregator::theoretical_query_ops(StatisticKind::Distinct, &(), &data).unwrap(); + assert_ops_within("distinct", distinct, || a.values.len()); + let member = + SetAggregator::theoretical_query_ops(StatisticKind::Membership, &(), &data) + .unwrap(); + assert_ops_within("membership", member, || a.contains("7")); + let merge = SetAggregator::theoretical_merge_ops(&(), &data).unwrap(); + assert_ops_within("merge", merge, || a.merge(&b).unwrap()); + } + } + + #[test] + fn bloom() { + let data = DistinctData::default(); + for p in BLOOM_SIZES { + let mut a = BloomRegular::with_dimensions(p.rows, p.cols); + let mut f = BloomFast::with_dimensions(p.rows, p.cols); + for id in 0..500 { + a.update(&key(id)); + f.update(&key(id)); + } + let (b, g) = (a.clone(), f.clone()); + let update = BloomRegular::theoretical_update_ops(&p, &data); + assert_ops_within("update", update, || a.update(&key(7))); + assert_ops_within("update", update, || f.update(&key(7))); + let query = + BloomRegular::theoretical_query_ops(StatisticKind::Membership, &p, &data).unwrap(); + assert_ops_within("query", query, || a.contains(&key(7))); + assert_ops_within("query", query, || f.contains(&key(7))); + let merge = BloomRegular::theoretical_merge_ops(&p, &data).unwrap(); + assert_ops_within("merge", merge, || a.merge(&b)); + assert_ops_within("merge", merge, || f.merge(&g)); + } + } +} diff --git a/tests/analytics/count_matrix.rs b/tests/analytics/count_matrix.rs new file mode 100644 index 00000000..2e009378 --- /dev/null +++ b/tests/analytics/count_matrix.rs @@ -0,0 +1,598 @@ +//! Count-Min, Count Sketch, Count Sketch with row L2 norms, CountMinHll and +//! MicroCm. + +use crate::support::*; +use asap_sketchlib::analytics::{MatrixParams, p_med}; +use asap_sketchlib::{ + CountMinSketch, CountSketch, CountSketchWithL2Norm, DataInput, ErrorMetric, FastPath, + RegularPath, SketchAnalytics, StatisticKind, Vector2D, +}; +use rand::Rng; + +type CmRegular = CountMinSketch, RegularPath>; +type CmFast = CountMinSketch, FastPath>; +type CsRegular = CountSketch, RegularPath>; +type CsFast = CountSketch, FastPath>; +type CsL2 = CountSketchWithL2Norm; + +const SIZES: [MatrixParams; 2] = [ + MatrixParams { rows: 3, cols: 64 }, + MatrixParams { + rows: 7, + cols: 1024, + }, +]; + +fn key(id: u64) -> DataInput<'static> { + DataInput::U64(id) +} + +#[test] +fn memory_is_exact_for_vector_storage() { + for p in SIZES { + let cm = CmRegular::with_dimensions(p.rows, p.cols); + assert_eq!( + ::theoretical_memory_bytes(&p, &()), + Some(cm.memory_bytes()) + ); + let cs = CsFast::with_dimensions(p.rows, p.cols); + assert_eq!( + ::theoretical_memory_bytes(&p, &()), + Some(cs.memory_bytes()) + ); + let l2 = CsL2::with_dimensions(p.rows, p.cols); + assert_eq!( + ::theoretical_memory_bytes(&p, &()), + Some(l2.memory_bytes()) + ); + } +} + +#[test] +fn count_min_overcounts_by_at_most_epsilon_n_with_probability_one_minus_delta() { + let p = MatrixParams { rows: 3, cols: 64 }; + for fast in [false, true] { + let bound = if fast { + CmFast::theoretical_error(StatisticKind::ItemFrequency, &p, &(), None) + } else { + CmRegular::theoretical_error(StatisticKind::ItemFrequency, &p, &(), None) + } + .unwrap(); + assert_eq!(bound.metric, ErrorMetric::L1); + let delta = bound.delta.unwrap(); + let trials = 800; + let mut failures = 0; + for trial in 0..trials { + let mut rng = rng(1, trial); + let keys = mixed_keys(&mut rng, 50, 64, 3000); + let (target, truth) = keys[(trial % 50) as usize]; + let mut regular = CmRegular::with_dimensions(p.rows, p.cols); + let mut fastpath = CmFast::with_dimensions(p.rows, p.cols); + for &(id, w) in &keys { + if fast { + fastpath.update_weighted(&key(id), w as i64); + } else { + regular.update_weighted(&key(id), w as i64); + } + } + let estimate = if fast { + fastpath.estimate(&key(target)) + } else { + regular.estimate(&key(target)) + }; + assert!(estimate >= truth as i64, "Count-Min undercounted"); + if (estimate - truth as i64) as f64 > bound.epsilon * l1(&keys) { + failures += 1; + } + } + assert!( + failures <= allowed_failures(trials as usize, delta), + "fast={fast}: {failures}/{trials} beyond eps={} at delta={delta}", + bound.epsilon + ); + } +} + +#[test] +fn count_sketch_is_off_by_at_most_epsilon_l2_with_probability_one_minus_delta() { + let p = MatrixParams { rows: 3, cols: 256 }; + let delta = 0.05; + for fast in [false, true] { + let bound = if fast { + CsFast::theoretical_error(StatisticKind::ItemFrequency, &p, &(), Some(delta)) + } else { + CsRegular::theoretical_error(StatisticKind::ItemFrequency, &p, &(), Some(delta)) + } + .unwrap(); + assert_eq!(bound.metric, ErrorMetric::L2); + assert_eq!(bound.delta, Some(delta)); + let trials = 800; + let mut failures = 0; + for trial in 0..trials { + let mut rng = rng(2, trial); + let keys = mixed_keys(&mut rng, 50, 64, 3000); + let (target, truth) = keys[(trial % 50) as usize]; + let mut regular = CsRegular::with_dimensions(p.rows, p.cols); + let mut fastpath = CsFast::with_dimensions(p.rows, p.cols); + for &(id, w) in &keys { + if fast { + fastpath.update_weighted(&key(id), w as i64); + } else { + regular.update_weighted(&key(id), w as i64); + } + } + let estimate = if fast { + fastpath.estimate(&key(target)) + } else { + regular.estimate(&key(target)) + }; + if (estimate - truth as f64).abs() > bound.epsilon * l2(&keys) { + failures += 1; + } + } + assert!( + failures <= allowed_failures(trials as usize, delta), + "fast={fast}: {failures}/{trials} beyond eps={} at delta={delta}", + bound.epsilon + ); + } +} + +#[test] +fn l2_norm_is_within_relative_epsilon_with_probability_one_minus_delta() { + let p = MatrixParams { + rows: 5, + cols: 1024, + }; + let bound = CsL2::theoretical_error(StatisticKind::FrequencyL2, &p, &(), None).unwrap(); + assert_eq!(bound.metric, ErrorMetric::Relative); + let delta = bound.delta.unwrap(); + assert_eq!(delta, p_med(5, 0.1)); + let trials = 800; + let mut failures = 0; + for trial in 0..trials { + let mut rng = rng(3, trial); + let keys = mixed_keys(&mut rng, 50, 64, 3000); + let mut sketch = CsL2::with_dimensions(p.rows, p.cols); + for &(id, w) in &keys { + sketch.update_weighted(&key(id), w as i64); + } + let truth = l2(&keys); + if (sketch.l2() - truth).abs() > bound.epsilon * truth { + failures += 1; + } + } + assert!( + failures <= allowed_failures(trials as usize, delta), + "{failures}/{trials} beyond eps={} at delta={delta}", + bound.epsilon + ); +} + +/// The fraction of key pairs that share a column of a one-row `FastPath` +/// Count-Min of `cols` columns: `Σ c_j(c_j − 1)` over `K(K − 1)`, where the +/// estimates of the `K` unit keys sum to `Σ c_j²`. +fn fast_path_pair_collision_rate(cols: usize, salt: u64) -> f64 { + let keys = 20_000u64; + let mut rng = rng(salt, cols as u64); + let ids: Vec = (0..keys).map(|_| rng.random()).collect(); + let mut sketch = CmFast::with_dimensions(1, cols); + for &id in &ids { + sketch.update_weighted(&key(id), 1); + } + let squares: i64 = ids.iter().map(|&id| sketch.estimate(&key(id))).sum(); + (squares as f64 - keys as f64) / (keys as f64 * (keys - 1) as f64) +} + +#[test] +fn fast_path_non_power_of_two_widths_collide_more_than_one_over_w() { + // At w = 48 a 6-bit slice modulo 48 puts 16 columns at twice the rate, + // so the pair collision rate is (w + 3r)/(w + r)² = 9/(8w), above 1/w. + for cols in [48usize, 40, 100] { + let w = cols as f64; + let r = (cols.next_power_of_two() - cols) as f64; + let rate = fast_path_pair_collision_rate(cols, 4); + let exact = (w + 3.0 * r) / ((w + r) * (w + r)); + assert!( + (rate / exact - 1.0).abs() < 0.03, + "w={cols}: {rate} vs {exact}" + ); + let q = asap_sketchlib::analytics::count_matrix::collision_probability(cols, true); + assert!(rate <= q * 1.03, "w={cols}: {rate} above q={q}"); + let p = MatrixParams { rows: 3, cols }; + let bound = CmFast::theoretical_error(StatisticKind::ItemFrequency, &p, &(), None).unwrap(); + assert!( + (bound.epsilon - std::f64::consts::E * exact).abs() < 1e-12, + "w={cols}: {bound:?}" + ); + assert!(bound.epsilon >= std::f64::consts::E * rate / 1.03); + } + let rate = fast_path_pair_collision_rate(48, 5); + assert!(rate > 1.08 / 48.0, "{rate}"); + assert_eq!( + asap_sketchlib::analytics::count_matrix::collision_probability(48, true), + 9.0 / (8.0 * 48.0) + ); +} + +#[test] +fn fast_path_count_min_at_a_non_power_of_two_width_overcounts_by_at_most_epsilon_n() { + let p = MatrixParams { rows: 2, cols: 48 }; + let bound = CmFast::theoretical_error(StatisticKind::ItemFrequency, &p, &(), None).unwrap(); + let delta = bound.delta.unwrap(); + let trials = 600; + let mut failures = 0; + for trial in 0..trials { + let mut rng = rng(6, trial); + let keys = mixed_keys(&mut rng, 50, 64, 3000); + let (target, truth) = keys[(trial % 50) as usize]; + let mut sketch = CmFast::with_dimensions(p.rows, p.cols); + for &(id, w) in &keys { + sketch.update_weighted(&key(id), w as i64); + } + let estimate = sketch.estimate(&key(target)); + assert!(estimate >= truth as i64, "Count-Min undercounted"); + if (estimate - truth as i64) as f64 > bound.epsilon * l1(&keys) { + failures += 1; + } + } + assert!( + failures <= allowed_failures(trials as usize, delta), + "{failures}/{trials} beyond eps={} at delta={delta}", + bound.epsilon + ); +} + +#[test] +fn count_min_sizing_meets_the_target_and_one_step_smaller_does_not() { + for (epsilon, delta) in [(0.1, 0.1), (0.01, 0.01), (0.001, 1e-4)] { + for fast in [false, true] { + let (p, bound) = if fast { + let p = + CmFast::params_for(StatisticKind::ItemFrequency, epsilon, delta, &()).unwrap(); + ( + p, + CmFast::theoretical_error(StatisticKind::ItemFrequency, &p, &(), Some(delta)), + ) + } else { + let p = CmRegular::params_for(StatisticKind::ItemFrequency, epsilon, delta, &()) + .unwrap(); + ( + p, + CmRegular::theoretical_error( + StatisticKind::ItemFrequency, + &p, + &(), + Some(delta), + ), + ) + }; + let bound = bound.unwrap(); + assert!(bound.epsilon <= epsilon && bound.delta.unwrap() <= delta); + let narrower = MatrixParams { + cols: if fast { p.cols / 2 } else { p.cols - 1 }, + ..p + }; + let error = |q: &MatrixParams, delta| { + if fast { + CmFast::theoretical_error(StatisticKind::ItemFrequency, q, &(), delta) + } else { + CmRegular::theoretical_error(StatisticKind::ItemFrequency, q, &(), delta) + } + }; + let wider_error = error(&narrower, None).unwrap(); + assert!(wider_error.epsilon > epsilon, "{p:?} is wider than needed"); + if p.rows > 1 { + let shallower = MatrixParams { + rows: p.rows - 1, + ..p + }; + assert!( + error(&shallower, Some(delta)).is_none(), + "{p:?} is deeper than needed" + ); + } + } + } +} + +#[test] +fn count_sketch_sizing_meets_the_target_within_the_row_cap() { + for (epsilon, delta) in [(0.1, 0.1), (0.05, 0.01), (0.01, 1e-4)] { + let p = CsRegular::params_for(StatisticKind::ItemFrequency, epsilon, delta, &()).unwrap(); + assert!(p.rows % 2 == 1 && p.rows <= 19, "{p:?}"); + let bound = + CsRegular::theoretical_error(StatisticKind::ItemFrequency, &p, &(), Some(delta)) + .unwrap(); + assert!(bound.epsilon <= epsilon, "{p:?} gives {bound:?}"); + let fast = CsFast::params_for(StatisticKind::ItemFrequency, epsilon, delta, &()).unwrap(); + assert!(fast.cols.is_power_of_two()); + let bound = + CsFast::theoretical_error(StatisticKind::ItemFrequency, &fast, &(), Some(delta)) + .unwrap(); + assert!(bound.epsilon <= epsilon, "{fast:?} gives {bound:?}"); + } + // At δ = 0.01 the fewest counters take 5 rows of about 9.5/ε² columns. + let p = CsRegular::params_for(StatisticKind::ItemFrequency, 0.01, 0.01, &()).unwrap(); + assert_eq!(p.rows, 5); + assert!((p.cols as f64 * 1e-4 - 9.5).abs() < 0.1, "{p:?}"); +} + +#[test] +fn l2_norm_sizing_meets_the_target_and_one_step_smaller_does_not() { + for (epsilon, delta) in [(0.2, 0.05), (0.1, 0.01), (0.05, 1e-3)] { + let p = CsL2::params_for(StatisticKind::FrequencyL2, epsilon, delta, &()).unwrap(); + let bound = + CsL2::theoretical_error(StatisticKind::FrequencyL2, &p, &(), Some(delta)).unwrap(); + assert!(bound.epsilon <= epsilon && bound.delta.unwrap() <= delta); + let narrower = MatrixParams { + cols: p.cols / 2, + ..p + }; + assert!( + CsL2::theoretical_error(StatisticKind::FrequencyL2, &narrower, &(), None) + .is_none_or(|b| b.epsilon > epsilon) + ); + if p.rows > 1 { + let shallower = MatrixParams { + rows: p.rows - 1, + ..p + }; + assert!( + CsL2::theoretical_error(StatisticKind::FrequencyL2, &shallower, &(), Some(delta)) + .is_none() + ); + } + } +} + +/// At (0.02, 0.01) the fewest counters take 9 rows of 16384, 135 hash bits, +/// and a shape with more counters fits the 128. +#[test] +fn l2_norm_item_sizing_takes_the_fewest_counters_that_fit_the_hash() { + let (epsilon, delta) = (0.02, 0.01); + let fewest = CsFast::params_for(StatisticKind::ItemFrequency, epsilon, delta, &()).unwrap(); + assert!( + fewest.rows * (fewest.cols.ilog2() as usize + 1) > 128, + "{fewest:?}" + ); + let p = CsL2::params_for(StatisticKind::ItemFrequency, epsilon, delta, &()).unwrap(); + assert!(p.rows * (p.cols.ilog2() as usize + 1) <= 128, "{p:?}"); + let bound = + CsL2::theoretical_error(StatisticKind::ItemFrequency, &p, &(), Some(delta)).unwrap(); + assert!(bound.epsilon <= epsilon); +} + +#[test] +fn count_sketch_sizing_gives_a_column_for_huge_epsilon() { + for epsilon in [1e100, 1e200, f64::INFINITY] { + let p = CsRegular::params_for(StatisticKind::ItemFrequency, epsilon, 0.01, &()).unwrap(); + assert!(p.cols >= 1, "{epsilon}: {p:?}"); + let bound = CsRegular::theoretical_error(StatisticKind::ItemFrequency, &p, &(), Some(0.01)); + assert!(bound.unwrap().epsilon <= epsilon); + } +} + +#[test] +fn total_is_exact_and_unanswered_statistics_have_no_bound() { + let p = MatrixParams::default(); + assert_eq!( + CmRegular::theoretical_error(StatisticKind::Total, &p, &(), None), + Some(asap_sketchlib::ErrorBound::EXACT) + ); + assert_eq!( + CsRegular::theoretical_error(StatisticKind::Total, &p, &(), None), + None + ); + assert_eq!( + CmRegular::theoretical_query_ops(StatisticKind::Quantile, &p, &()), + None + ); +} + +#[cfg(feature = "op-count")] +mod ops { + use super::*; + + fn loaded(mut sketch: S, update: impl Fn(&mut S, u64)) -> S { + for id in 0..500 { + update(&mut sketch, id); + } + sketch + } + + macro_rules! matrix_ops { + ($name:ident, $ty:ty, $stats:expr) => { + #[test] + fn $name() { + for p in SIZES { + let mut a = loaded(<$ty>::with_dimensions(p.rows, p.cols), |s, id| { + s.update(&key(id)) + }); + let b = a.clone(); + let update = <$ty>::theoretical_update_ops(&p, &()); + assert_ops_within("update", update, || a.update(&key(7))); + let query = <$ty>::theoretical_query_ops(StatisticKind::ItemFrequency, &p, &()) + .unwrap(); + assert_ops_within("query", query, || a.estimate(&key(7))); + for (statistic, read) in $stats { + let bound = <$ty>::theoretical_query_ops(statistic, &p, &()).unwrap(); + assert_ops_within("query", bound, || read(&a)); + } + let merge = <$ty>::theoretical_merge_ops(&p, &()).unwrap(); + assert_ops_within("merge", merge, || a.merge(&b)); + } + } + }; + } + + type Read = fn(&S) -> f64; + matrix_ops!( + count_min_regular, + CmRegular, + [(StatisticKind::Total, (|s| s.total()) as Read)] + ); + matrix_ops!( + count_min_fast, + CmFast, + [(StatisticKind::Total, (|s| s.total()) as Read)] + ); + matrix_ops!( + count_sketch_regular, + CsRegular, + [] as [(StatisticKind, Read); 0] + ); + matrix_ops!( + count_sketch_fast, + CsFast, + [] as [(StatisticKind, Read); 0] + ); + + #[test] + fn count_sketch_with_l2_norm() { + for p in SIZES { + let mut a = loaded(CsL2::with_dimensions(p.rows, p.cols), |s, id| { + s.update_weighted(&key(id), 1) + }); + let b = a.clone(); + let update = CsL2::theoretical_update_ops(&p, &()); + assert_ops_within("update", update, || a.update_weighted(&key(7), 1)); + for (statistic, read) in [ + ( + StatisticKind::ItemFrequency, + (|s: &CsL2| s.estimate(&key(7))) as Read, + ), + (StatisticKind::FrequencyL2, |s: &CsL2| s.l2()), + ] { + let bound = CsL2::theoretical_query_ops(statistic, &p, &()).unwrap(); + assert_ops_within("query", bound, || read(&a)); + } + let merge = CsL2::theoretical_merge_ops(&p, &()).unwrap(); + assert_ops_within("merge", merge, || a.merge(&b)); + } + } +} + +#[cfg(feature = "experimental")] +mod experimental { + use super::*; + use asap_sketchlib::analytics::{CountMinHllParams, MicroCmData, MicroCmParams}; + use asap_sketchlib::sketches::microscope::{MicroParams, SubWindowClock}; + use asap_sketchlib::{CountMinHll, MicroCm}; + + type Hll = CountMinHll; + type Micro = MicroCm; + + const HLL_SIZES: [CountMinHllParams; 2] = [ + CountMinHllParams { + rows: 2, + cols: 16, + precision: 4, + }, + CountMinHllParams { + rows: 3, + cols: 64, + precision: 8, + }, + ]; + + const MICRO_SIZES: [(MicroCmParams, u64); 2] = [ + ( + MicroCmParams { + rows: 2, + cols: 16, + t: 4, + c: 2, + }, + 32, + ), + ( + MicroCmParams { + rows: 4, + cols: 64, + t: 12, + c: 3, + }, + 256, + ), + ]; + + fn micro(p: MicroCmParams, per_sub_window: u64) -> MicroCm { + MicroCm::with_dimensions( + p.rows, + p.cols, + MicroParams::new(p.t, p.c), + SubWindowClock::count_based(per_sub_window), + 7, + ) + } + + #[test] + fn memory_matches_the_storage() { + for p in HLL_SIZES { + let sketch = Hll::with_dimensions(p.rows, p.cols, p.precision); + assert_eq!( + ::theoretical_memory_bytes(&p, &()), + Some(sketch.memory_bytes()) + ); + } + for (p, per) in MICRO_SIZES { + let sketch = micro(p, per); + let data = MicroCmData { + items_per_sub_window: Some(per), + }; + assert_eq!( + ::theoretical_memory_bytes(&p, &data), + Some(sketch.memory_bytes()) + ); + } + } + + #[cfg(feature = "op-count")] + #[test] + fn count_min_hll_ops() { + for p in HLL_SIZES { + let mut a = Hll::with_dimensions(p.rows, p.cols, p.precision); + for id in 0..300 { + a.update(&key(id % 17), &key(id)); + } + let b = a.clone(); + let update = Hll::theoretical_update_ops(&p, &()); + assert_ops_within("update", update, || a.update(&key(1), &key(99))); + let query = Hll::theoretical_query_ops(StatisticKind::KeyDistinct, &p, &()).unwrap(); + assert_ops_within("query", query, || a.estimate(&key(1))); + let merge = Hll::theoretical_merge_ops(&p, &()).unwrap(); + assert_ops_within("merge", merge, || a.merge(&b).unwrap()); + } + } + + #[cfg(feature = "op-count")] + #[test] + fn micro_cm_ops() { + for (p, per) in MICRO_SIZES { + let data = MicroCmData { + items_per_sub_window: Some(per), + }; + let mut a = micro(p, per); + let mut b = micro(p, per); + let updates = per * (p.t as u64 + 2); + let ((), ops) = asap_sketchlib::analytics::ops::count(|| { + for id in 0..updates { + a.update(&key(id % 23)); + b.update(&key(id % 29)); + } + }); + let average = ops as f64 / (2 * updates) as f64; + let bound = Micro::theoretical_update_ops(&p, &data); + assert!( + average <= bound, + "update averages {average}, formula {bound}" + ); + let query = + Micro::theoretical_query_ops(StatisticKind::ItemFrequency, &p, &data).unwrap(); + assert_ops_within("query", query, || a.estimate(&key(3))); + let merge = Micro::theoretical_merge_ops(&p, &data).unwrap(); + assert_ops_within("merge", merge, || a.merge(&b).unwrap()); + } + } +} diff --git a/tests/analytics/frameworks.rs b/tests/analytics/frameworks.rs new file mode 100644 index 00000000..809a0d1e --- /dev/null +++ b/tests/analytics/frameworks.rs @@ -0,0 +1,1233 @@ +//! Hydra, ExponentialHistogram, NitroBatch, TumblingWindow, +//! HashSketchEnsemble, and the experimental ExponentialHistogramUnivMon. + +use crate::support::*; +use asap_sketchlib::analytics::{ + CellData, EhCell, EhData, EhParams, EnsembleCell, EnsembleParams, FoldData, FoldParams, + HllParams, HydraCell, HydraData, HydraParams, KllParams, MatrixParams, NitroData, NitroParams, + StreamData, TumblingData, TumblingParams, +}; +use asap_sketchlib::input::HydraCounter; +use asap_sketchlib::{ + CountMinSketch, CountMinSketchFold, CountMinSketchFoldConfig, CountSketchWithL2Norm, DataInput, + EhBucketSketch, EnsembleSketch, ErrorMetric, Ertl, ExponentialHistogram, FastPath, + HashSketchEnsemble, Hydra, HyperLogLog, Kll, NitroBatch, SketchAnalytics, StatisticKind, + TumblingWindow, Vector2D, +}; +use rand::Rng; + +type Cm = CountMinSketch, FastPath>; +type Nitro = NitroBatch; +type FoldWindow = TumblingWindow; +type Ensemble = HashSketchEnsemble; +type Fold = CountMinSketchFold; + +/// Hydra grid sizes, with Count-Min cells. +const HYDRA_SIZES: [(usize, usize, usize, MatrixParams); 2] = [ + (3, 64, 2, MatrixParams { rows: 2, cols: 32 }), + (5, 256, 3, MatrixParams { rows: 3, cols: 128 }), +]; + +fn schema(arity: usize) -> Vec { + (0..arity) + .map(|i| String::from(["region", "os", "app"][i])) + .collect() +} + +fn hydra(rows: usize, cols: usize, arity: usize, cell: MatrixParams) -> Hydra { + let prototype = HydraCounter::CountMinSketch(Cm::with_dimensions(cell.rows, cell.cols)); + Hydra::with_schema(rows, cols, schema(arity), prototype).unwrap() +} + +/// The schema values of flow `id`: one per column, all distinct. +fn flow_values(id: u64, arity: usize) -> Vec { + (0..arity) + .map(|i| format!("{:x}", id ^ (i as u64) << 60)) + .collect() +} + +fn hydra_update(h: &mut Hydra, id: u64, arity: usize, value: &DataInput, weight: i32) { + let values = flow_values(id, arity); + let key: Vec<&str> = values.iter().map(String::as_str).collect(); + h.update(&key, value, Some(weight)).unwrap(); +} + +fn hydra_params(rows: usize, cols: usize, arity: usize, cell: MatrixParams) -> HydraParams { + HydraParams { + rows, + cols, + arity, + cell: HydraCell::CountMin(cell), + } +} + +fn label_bytes(arity: usize) -> usize { + schema(arity).iter().map(String::len).sum() +} + +#[test] +fn hydra_memory_is_exact_for_count_min_cells() { + for (rows, cols, arity, cell) in HYDRA_SIZES { + let p = hydra_params(rows, cols, arity, cell); + let data = HydraData { + label_bytes: Some(label_bytes(arity)), + ..HydraData::default() + }; + let mut h = hydra(rows, cols, arity, cell); + hydra_update(&mut h, 7, arity, &DataInput::U64(1), 3); + assert_eq!( + Hydra::theoretical_memory_bytes(&p, &data), + Some(h.memory_bytes()) + ); + } +} + +#[test] +fn hydra_memory_covers_escaped_labels_and_needs_label_bytes() { + let (rows, cols, cell) = (3, 64, MatrixParams { rows: 2, cols: 32 }); + let labels = vec![String::from("a:b"), String::from("x;y\\z")]; + let escaped = "a\\:b".len() + "x\\;y\\\\z".len(); + let prototype = HydraCounter::CountMinSketch(Cm::with_dimensions(cell.rows, cell.cols)); + let h = Hydra::with_schema(rows, cols, labels, prototype).unwrap(); + let p = hydra_params(rows, cols, 2, cell); + let data = HydraData { + label_bytes: Some(escaped), + ..HydraData::default() + }; + let bound = Hydra::theoretical_memory_bytes(&p, &data).unwrap(); + assert!(bound >= h.memory_bytes(), "{bound} < {}", h.memory_bytes()); + assert_eq!( + Hydra::theoretical_memory_bytes(&p, &HydraData::default()), + None + ); +} + +/// The three heaviest keys each carry more than `ε·N`, and a Zipf head and +/// a weighted tail spread colliding mass over every column. +#[test] +fn hydra_count_min_frequency_is_within_epsilon_n_with_probability_one_minus_delta() { + let (rows, cols, arity) = (3, 512, 1); + let cell = MatrixParams { rows: 3, cols: 256 }; + let p = hydra_params(rows, cols, arity, cell); + let bound = Hydra::theoretical_error( + StatisticKind::ItemFrequency, + &p, + &HydraData::default(), + None, + ) + .unwrap(); + assert_eq!(bound.metric, ErrorMetric::L1); + let delta = bound.delta.unwrap(); + let value = DataInput::U64(1); + let trials = 200; + let mut failures = 0; + for trial in 0..trials { + let mut rng = rng(101, trial); + let mut flows = zipf_keys(&mut rng, 30, 6000); + flows.extend((0..2000).map(|_| (rng.random(), 5))); + let mut h = hydra(rows, cols, arity, cell); + for &(id, w) in &flows { + hydra_update(&mut h, id, arity, &value, w as i32); + } + let (target, truth) = flows[(trial % 3) as usize]; + assert!(truth as f64 > 2.0 * bound.epsilon * l1(&flows)); + let values = flow_values(target, arity); + let estimate = h + .query_frequency(&[Some(values[0].as_str())], &value) + .unwrap(); + if (estimate - truth as f64).abs() > bound.epsilon * l1(&flows) { + failures += 1; + } + } + assert!( + failures <= allowed_failures(trials as usize, delta), + "{failures}/{trials} beyond eps={} at delta={delta}", + bound.epsilon + ); +} + +#[test] +fn hydra_sizing_meets_the_target() { + let data = HydraData { + arity: Some(2), + ..HydraData::default() + }; + for (epsilon, delta) in [(0.2, 0.2), (0.05, 0.01), (0.01, 1e-4)] { + let p = Hydra::params_for(StatisticKind::ItemFrequency, epsilon, delta, &data).unwrap(); + assert_eq!(p.arity, 2); + let bound = + Hydra::theoretical_error(StatisticKind::ItemFrequency, &p, &data, Some(delta)).unwrap(); + assert!( + bound.epsilon <= epsilon && bound.delta.unwrap() <= delta, + "{p:?} gives {bound:?} for ({epsilon}, {delta})" + ); + let narrower = HydraParams { + cols: p.cols / 2, + ..p + }; + let narrower = + Hydra::theoretical_error(StatisticKind::ItemFrequency, &narrower, &data, Some(delta)) + .unwrap(); + assert!(narrower.epsilon > epsilon / 2.0); + } +} + +#[test] +fn hydra_sizing_meets_the_target_for_every_cell_kind() { + let data = HydraData { + arity: Some(3), + n: Some(100_000), + total_weight: Some(100_000), + group_weight: Some(20_000), + group_distinct: Some(5_000), + group_l2: Some(8_000.0), + merged: Some(false), + ..HydraData::default() + }; + for statistic in [ + StatisticKind::Quantile, + StatisticKind::Rank, + StatisticKind::FrequencyL2, + ] { + for (epsilon, delta) in [(0.3, 0.1), (0.2, 0.05)] { + let p = Hydra::params_for(statistic, epsilon, delta, &data) + .unwrap_or_else(|| panic!("{statistic:?} at ({epsilon}, {delta})")); + let bound = Hydra::theoretical_error(statistic, &p, &data, Some(delta)).unwrap(); + assert!( + bound.epsilon <= epsilon && bound.delta.is_some_and(|d| d <= delta), + "{statistic:?}: {p:?} gives {bound:?} for ({epsilon}, {delta})" + ); + } + } +} + +#[test] +fn hydra_hll_distinct_has_no_bound() { + let data = HydraData { + n: Some(100_000), + group_distinct: Some(5_000), + ..HydraData::default() + }; + let p = HydraParams { + rows: 5, + cols: 1 << 12, + arity: 1, + cell: HydraCell::Hll(HllParams::default()), + }; + assert_eq!( + Hydra::theoretical_error(StatisticKind::Distinct, &p, &data, Some(0.1)), + None + ); + assert_eq!( + Hydra::params_for(StatisticKind::Distinct, 0.3, 0.1, &data), + None + ); +} + +#[test] +fn eh_hll_distinct_has_no_bound() { + let p = EhParams { + k: 8, + window: 1000, + cell: EhCell::Hll(HllParams::default()), + }; + let data = EhData { + window_arrivals: Some(1000), + ..EhData::default() + }; + for delta in [None, Some(0.1)] { + assert_eq!( + ExponentialHistogram::theoretical_error(StatisticKind::Distinct, &p, &data, delta), + None + ); + } +} + +#[test] +fn hydra_kll_bounds_need_an_unmerged_hydra() { + let unmerged = HydraData { + n: Some(100_000), + total_weight: Some(100_000), + group_weight: Some(20_000), + merged: Some(false), + ..HydraData::default() + }; + for statistic in [StatisticKind::Quantile, StatisticKind::Rank] { + let p = Hydra::params_for(statistic, 0.3, 0.1, &unmerged).unwrap(); + assert!(Hydra::theoretical_error(statistic, &p, &unmerged, Some(0.1)).is_some()); + for merged in [None, Some(true)] { + let data = HydraData { merged, ..unmerged }; + assert_eq!( + Hydra::theoretical_error(statistic, &p, &data, Some(0.1)), + None + ); + assert_eq!(Hydra::params_for(statistic, 0.3, 0.1, &data), None); + } + } + let merged = HydraData { + merged: None, + ..unmerged + }; + let item = StatisticKind::ItemFrequency; + let p = Hydra::params_for(item, 0.2, 0.2, &merged).unwrap(); + assert!(Hydra::theoretical_error(item, &p, &merged, Some(0.2)).is_some()); +} + +#[cfg(feature = "op-count")] +#[test] +fn hydra_ops_are_within_their_formulas() { + let data = HydraData::default(); + for (i, (rows, cols, arity, cell)) in HYDRA_SIZES.into_iter().enumerate() { + let p = hydra_params(rows, cols, arity, cell); + let mut a = hydra(rows, cols, arity, cell); + let mut b = hydra(rows, cols, arity, cell); + let mut rng = rng(102, i as u64); + for _ in 0..500 { + hydra_update( + &mut a, + rng.random(), + arity, + &DataInput::U64(rng.random_range(0..9)), + 1, + ); + hydra_update( + &mut b, + rng.random(), + arity, + &DataInput::U64(rng.random_range(0..9)), + 1, + ); + } + let update = Hydra::theoretical_update_ops(&p, &data); + let values = flow_values(rng.random(), arity); + let key: Vec<&str> = values.iter().map(String::as_str).collect(); + assert_ops_within("update", update, || { + a.update(&key, &DataInput::U64(3), None).unwrap() + }); + let query: Vec> = std::iter::once(Some(key[0])) + .chain((1..arity).map(|_| None)) + .collect(); + let frequency = Hydra::theoretical_query_ops(StatisticKind::ItemFrequency, &p, &data); + assert_ops_within("frequency", frequency.unwrap(), || { + a.query_frequency(&query, &DataInput::U64(3)).unwrap() + }); + let total = Hydra::theoretical_query_ops(StatisticKind::Total, &p, &data); + assert_ops_within("group total", total.unwrap(), || { + a.query_group_total(&query).unwrap() + }); + let merge = Hydra::theoretical_merge_ops(&p, &data).unwrap(); + assert_ops_within("merge", merge, || a.merge(&b).unwrap()); + } +} + +// --------------------------------------------------------------------------- +// ExponentialHistogram +// --------------------------------------------------------------------------- + +fn eh_count_min(k: usize, window: u64, cell: MatrixParams) -> ExponentialHistogram { + ExponentialHistogram::new( + k, + window, + EhBucketSketch::CountMinSketch(Cm::with_dimensions(cell.rows, cell.cols)), + ) +} + +fn eh_l2(k: usize, window: u64, cell: MatrixParams) -> ExponentialHistogram { + ExponentialHistogram::new( + k, + window, + EhBucketSketch::CountSketchWithL2Norm(CountSketchWithL2Norm::with_dimensions( + cell.rows, cell.cols, + )), + ) +} + +#[test] +fn eh_memory_bounds_memory_bytes() { + for (k, cell) in [ + (4, MatrixParams { rows: 2, cols: 32 }), + (16, MatrixParams { rows: 3, cols: 64 }), + ] { + for by_mass in [false, true] { + let mut eh = if by_mass { + eh_l2(k, 300, cell) + } else { + eh_count_min(k, 300, cell) + }; + let mut rng = rng(103, k as u64); + let mut most = 0; + for t in 0..1000 { + eh.update(t, &DataInput::U64(rng.random_range(0..50))); + most = most.max(eh.bucket_count()); + } + let params = EhParams { + k, + window: 300, + cell: if by_mass { + EhCell::CountSketchWithL2Norm(cell) + } else { + EhCell::CountMin(cell) + }, + }; + let data = EhData { + buckets: Some(most), + ..EhData::default() + }; + let theory = ExponentialHistogram::theoretical_memory_bytes(¶ms, &data).unwrap(); + assert!( + theory >= eh.memory_bytes(), + "{theory} < {}", + eh.memory_bytes() + ); + if !by_mass { + let from_arrivals = EhData { + window_arrivals: Some(301), + ..EhData::default() + }; + let bound = ExponentialHistogram::theoretical_memory_bytes(¶ms, &from_arrivals); + assert!(bound.unwrap() >= eh.memory_bytes()); + } else { + let unknown = ExponentialHistogram::theoretical_memory_bytes( + ¶ms, + &EhData { + window_arrivals: Some(301), + ..EhData::default() + }, + ); + assert_eq!(unknown, None); + } + } + } +} + +#[test] +fn eh_count_min_frequency_by_count_is_within_epsilon_n_w_with_probability_one_minus_delta() { + let (k, window) = (64, 1000u64); + let cell = MatrixParams { rows: 2, cols: 64 }; + let params = EhParams { + k, + window, + cell: EhCell::CountMin(cell), + }; + let bound = ExponentialHistogram::theoretical_error( + StatisticKind::ItemFrequency, + ¶ms, + &EhData::default(), + None, + ) + .unwrap(); + assert_eq!(bound.metric, ErrorMetric::L1); + let delta = bound.delta.unwrap(); + let trials = 200; + let mut failures = 0; + for trial in 0..trials { + let mut rng = rng(104, trial); + let target: u64 = rng.random(); + let end = window + rng.random_range(0..600); + let mut eh = eh_count_min(k, window, cell); + let mut keys = Vec::with_capacity(end as usize + 1); + for t in 0..=end { + let key = if rng.random_bool(0.5) { + target + } else { + rng.random() + }; + keys.push(key); + eh.update(t, &DataInput::U64(key)); + } + let start = end - window; + let truth = keys[start as usize..] + .iter() + .filter(|&&k| k == target) + .count(); + let merged = eh.query_interval(start, end).unwrap(); + let estimate = merged.query(&DataInput::U64(target)).unwrap(); + if (estimate - truth as f64).abs() > bound.epsilon * (window + 1) as f64 { + failures += 1; + } + } + assert!( + failures <= allowed_failures(trials as usize, delta), + "{failures}/{trials} beyond eps={} at delta={delta}", + bound.epsilon + ); +} + +#[cfg(feature = "op-count")] +#[test] +fn eh_ops_are_within_their_formulas() { + use asap_sketchlib::analytics::ops; + for (i, (k, cell)) in [ + (4, MatrixParams { rows: 2, cols: 32 }), + (16, MatrixParams { rows: 3, cols: 64 }), + ] + .into_iter() + .enumerate() + { + for by_mass in [false, true] { + let window = 400; + let mut eh = if by_mass { + eh_l2(k, window, cell) + } else { + eh_count_min(k, window, cell) + }; + let mut rng = rng(105, i as u64); + let updates = 1500; + let mut most = 0; + let ((), counted) = ops::count(|| { + for t in 0..updates { + eh.update(t, &DataInput::U64(rng.random_range(0..40))); + most = most.max(eh.bucket_count()); + } + }); + let params = EhParams { + k, + window, + cell: if by_mass { + EhCell::CountSketchWithL2Norm(cell) + } else { + EhCell::CountMin(cell) + }, + }; + let data = EhData { + buckets: Some(most), + ..EhData::default() + }; + let average = counted as f64 / updates as f64; + let bound = ExponentialHistogram::theoretical_update_ops(¶ms, &data); + assert!( + average <= bound, + "by_mass={by_mass}: update averages {average}, formula {bound}" + ); + let statistic = if by_mass { + StatisticKind::FrequencyL2 + } else { + StatisticKind::ItemFrequency + }; + let query = + ExponentialHistogram::theoretical_query_ops(statistic, ¶ms, &data).unwrap(); + assert_ops_within("query", query, || { + let merged = eh.query_interval(updates - window, updates - 1).unwrap(); + match &merged { + EhBucketSketch::CountSketchWithL2Norm(s) => s.l2(), + other => other.query(&DataInput::U64(3)).unwrap(), + } + }); + } + } +} + +// --------------------------------------------------------------------------- +// NitroBatch +// --------------------------------------------------------------------------- + +#[test] +fn nitro_memory_is_exact() { + for cell in [ + MatrixParams { rows: 3, cols: 64 }, + MatrixParams { rows: 5, cols: 512 }, + ] { + let p = NitroParams { + rate: 0.25, + inner: cell, + }; + let nitro = Nitro::with_target_and_seed(0.25, Cm::with_dimensions(cell.rows, cell.cols), 1); + assert_eq!( + Nitro::theoretical_memory_bytes(&p, &NitroData::default()), + Some(nitro.memory_bytes()) + ); + } +} + +/// A unit-update stream of `keys`, each repeated its weight times. +fn stream(keys: &[(u64, u64)]) -> Vec { + keys.iter() + .flat_map(|&(id, w)| std::iter::repeat_n(id as i64, w as usize)) + .collect() +} + +#[test] +fn nitro_count_min_frequency_is_within_epsilon_n_with_probability_one_minus_delta() { + let cell = MatrixParams { rows: 3, cols: 64 }; + let rate = 0.25; + let delta = 0.5; + let trials = 300; + let mut failures = 0; + let mut checked = None; + for trial in 0..trials { + let mut rng = rng(106, trial); + let mut keys = vec![(rng.random::() >> 1, 4000)]; + keys.extend(mixed_keys(&mut rng, 20, 400, 6000)); + let data = stream(&keys); + let p = NitroParams { rate, inner: cell }; + let n = NitroData { + total_weight: Some(data.len() as u64), + }; + let bound = + Nitro::theoretical_error(StatisticKind::ItemFrequency, &p, &n, Some(delta)).unwrap(); + assert_eq!(bound.metric, ErrorMetric::L1); + checked.get_or_insert(bound.epsilon); + let mut nitro = + Nitro::with_target_and_seed(rate, Cm::with_dimensions(cell.rows, cell.cols), trial); + nitro.update_batch(&data); + let (target, truth) = keys[0]; + let estimate = nitro.estimate_median(&DataInput::I64(target as i64)); + if (estimate - truth as f64).abs() > bound.epsilon * data.len() as f64 { + failures += 1; + } + } + assert!( + failures <= allowed_failures(trials as usize, delta), + "{failures}/{trials} beyond eps={:?} at delta={delta}", + checked + ); +} + +#[test] +fn nitro_has_no_count_sketch_bound_and_needs_n() { + type NitroCs = NitroBatch, FastPath>>; + let p = NitroParams::default(); + let n = NitroData { + total_weight: Some(1000), + }; + assert!(NitroCs::theoretical_error(StatisticKind::ItemFrequency, &p, &n, None).is_none()); + assert!( + Nitro::theoretical_error( + StatisticKind::ItemFrequency, + &p, + &NitroData::default(), + None + ) + .is_none() + ); + // δ at or below P_med(d, q·w/e) leaves no room for sampling. + assert!(Nitro::theoretical_error(StatisticKind::ItemFrequency, &p, &n, Some(0.01)).is_none()); +} + +#[cfg(feature = "op-count")] +#[test] +fn nitro_ops_are_within_their_formulas() { + use asap_sketchlib::analytics::ops; + for (i, cell) in [ + MatrixParams { rows: 3, cols: 64 }, + MatrixParams { rows: 5, cols: 512 }, + ] + .into_iter() + .enumerate() + { + let rate = 0.1; + let p = NitroParams { rate, inner: cell }; + let data = NitroData::default(); + let mut a = Nitro::with_target_and_seed(rate, Cm::with_dimensions(cell.rows, cell.cols), 3); + let mut b = Nitro::with_target_and_seed(rate, Cm::with_dimensions(cell.rows, cell.cols), 4); + let mut rng = rng(107, i as u64); + let batch: Vec = (0..200_000).map(|_| rng.random_range(0..5000)).collect(); + let ((), counted) = ops::count(|| a.update_batch(&batch)); + b.update_batch(&batch[..1000]); + let n = batch.len() as f64; + let mean = Nitro::theoretical_update_ops(&p, &data) * n; + // The admitted count is binomial: allow four standard deviations. + let slack = 4.0 * cell.rows as f64 * (n * rate * (1.0 - rate)).sqrt(); + assert!( + counted as f64 <= mean + slack, + "updates counted {counted}, formula {mean} + {slack}" + ); + let query = Nitro::theoretical_query_ops(StatisticKind::ItemFrequency, &p, &data); + assert_ops_within("query", query.unwrap(), || { + a.estimate_median(&DataInput::I64(7)) + }); + let merge = Nitro::theoretical_merge_ops(&p, &data).unwrap(); + assert_ops_within("merge", merge, || a.merge(&b)); + } +} + +// --------------------------------------------------------------------------- +// TumblingWindow +// --------------------------------------------------------------------------- + +const FOLD_SIZES: [FoldParams; 2] = [ + FoldParams { + rows: 2, + full_cols: 64, + fold_level: 2, + heap_size: 8, + }, + FoldParams { + rows: 3, + full_cols: 256, + fold_level: 3, + heap_size: 16, + }, +]; + +fn fold_window(p: FoldParams, max_windows: usize, pool: usize) -> FoldWindow { + let config = CountMinSketchFoldConfig { + rows: p.rows, + full_cols: p.full_cols, + fold_level: p.fold_level, + heap_size: p.heap_size, + }; + TumblingWindow::new(10, max_windows, config, pool) +} + +#[test] +fn tumbling_memory_bounds_memory_bytes() { + for (i, inner) in FOLD_SIZES.into_iter().enumerate() { + for (max_windows, pool) in [(4, 2), (3, 8), (0, 0)] { + let mut w = fold_window(inner, max_windows, pool); + let mut rng = rng(108, i as u64); + for t in 0..400 { + w.update_weighted(t, &DataInput::U64(rng.random_range(0..100)), 1); + } + let p = TumblingParams { + window_size: 10, + max_windows, + pool_initial_size: pool, + inner, + }; + let data = TumblingData { + inner: FoldData { + distinct: Some(100), + key_bytes: Some(0), + ..FoldData::default() + }, + ..TumblingData::default() + }; + let theory = FoldWindow::theoretical_memory_bytes(&p, &data).unwrap(); + assert!( + theory >= w.memory_bytes(), + "{theory} < {}", + w.memory_bytes() + ); + } + } +} + +#[test] +fn tumbling_error_is_the_inner_bound_and_kll_windows_have_none() { + let p = TumblingParams::::default(); + let data = TumblingData::::default(); + assert_eq!( + FoldWindow::theoretical_error(StatisticKind::ItemFrequency, &p, &data, None), + Fold::theoretical_error(StatisticKind::ItemFrequency, &p.inner, &data.inner, None) + ); + let kll = TumblingParams::::default(); + let stream = TumblingData::::default(); + assert!( + TumblingWindow::::theoretical_error(StatisticKind::Quantile, &kll, &stream, None) + .is_none() + ); +} + +#[cfg(feature = "op-count")] +#[test] +fn tumbling_ops_are_within_their_formulas() { + for (i, inner) in FOLD_SIZES.into_iter().enumerate() { + let max_windows = 4; + let mut w = fold_window(inner, max_windows, 2); + let p = TumblingParams { + window_size: 10, + max_windows, + pool_initial_size: 2, + inner, + }; + let data = TumblingData { + boundaries: Some(1), + inner: FoldData { + distinct: Some(200), + key_bytes: Some(0), + ..FoldData::default() + }, + ..TumblingData::default() + }; + let unbounded = TumblingData { + boundaries: None, + ..data + }; + assert_eq!( + FoldWindow::theoretical_update_ops(&p, &unbounded), + f64::INFINITY + ); + let update = FoldWindow::theoretical_update_ops(&p, &data); + let mut rng = rng(109, i as u64); + for t in 0..120 { + let key = DataInput::U64(rng.random_range(0..200)); + // Each update opens the next window, crossing one boundary. + assert_ops_within("update", update, || w.update_weighted(t * 10, &key, 1)); + } + for t in [1, 3, 9] { + let windows = TumblingData { + windows: Some(t), + ..data + }; + let query = + FoldWindow::theoretical_query_ops(StatisticKind::ItemFrequency, &p, &windows) + .unwrap(); + assert_ops_within("query_recent", query, || w.query_recent(t)); + } + } +} + +// --------------------------------------------------------------------------- +// HashSketchEnsemble +// --------------------------------------------------------------------------- + +fn ensemble(cell: MatrixParams) -> HashSketchEnsemble { + HashSketchEnsemble::new(vec![ + EnsembleSketch::from(Cm::with_dimensions(cell.rows, cell.cols)), + EnsembleSketch::from(Cm::with_dimensions(cell.rows, cell.cols)), + EnsembleSketch::from(HyperLogLog::::new()), + ]) + .unwrap() +} + +fn ensemble_params(cell: MatrixParams, index: usize) -> EnsembleParams { + let count_min = EnsembleCell::CountMin { + shape: cell, + counter_bytes: 4, + }; + EnsembleParams { + sketches: vec![ + count_min, + count_min, + EnsembleCell::HllErtl(HllParams::default()), + ], + index, + } +} + +#[test] +fn ensemble_memory_is_exact_and_each_sketch_keeps_its_bound() { + for cell in [ + MatrixParams { rows: 3, cols: 64 }, + MatrixParams { + rows: 4, + cols: 1024, + }, + ] { + let p = ensemble_params(cell, 0); + let e = ensemble(cell); + assert_eq!( + Ensemble::theoretical_memory_bytes(&p, &CellData::default()), + Some(e.memory_bytes()) + ); + assert_eq!( + Ensemble::theoretical_error( + StatisticKind::ItemFrequency, + &p, + &CellData::default(), + None + ), + Cm::theoretical_error(StatisticKind::ItemFrequency, &cell, &(), None) + ); + } +} + +#[test] +fn ensemble_memory_reads_each_matrix_counter_width() { + type Cm64 = CountMinSketch, FastPath>; + let shape = MatrixParams { rows: 3, cols: 64 }; + let e = Ensemble::new(vec![ + EnsembleSketch::from(Cm64::with_dimensions(shape.rows, shape.cols)), + EnsembleSketch::from(Cm::with_dimensions(shape.rows, shape.cols)), + ]) + .unwrap(); + let p = EnsembleParams { + sketches: vec![ + EnsembleCell::CountMin { + shape, + counter_bytes: 8, + }, + EnsembleCell::CountMin { + shape, + counter_bytes: 4, + }, + ], + index: 0, + }; + assert_eq!( + Ensemble::theoretical_memory_bytes(&p, &CellData::default()), + Some(e.memory_bytes()) + ); +} + +#[cfg(feature = "op-count")] +#[test] +fn ensemble_ops_are_within_their_formulas() { + for (i, cell) in [ + MatrixParams { rows: 3, cols: 64 }, + MatrixParams { + rows: 4, + cols: 1024, + }, + ] + .into_iter() + .enumerate() + { + let mut e = ensemble(cell); + let data = CellData::default(); + let update = Ensemble::theoretical_update_ops(&ensemble_params(cell, 0), &data); + let mut rng = rng(110, i as u64); + for _ in 0..300 { + let key = DataInput::U64(rng.random()); + assert_ops_within("update", update, || e.update(&key)); + } + let frequency = Ensemble::theoretical_query_ops( + StatisticKind::ItemFrequency, + &ensemble_params(cell, 1), + &data, + ); + assert_ops_within("frequency", frequency.unwrap(), || { + e.estimate(1, &DataInput::U64(5)).unwrap() + }); + let distinct = Ensemble::theoretical_query_ops( + StatisticKind::Distinct, + &ensemble_params(cell, 2), + &data, + ); + assert_ops_within("distinct", distinct.unwrap(), || e.cardinality(2).unwrap()); + } +} + +// --------------------------------------------------------------------------- +// ExponentialHistogramUnivMon +// --------------------------------------------------------------------------- + +#[cfg(feature = "experimental")] +mod eh_univmon { + use super::*; + use asap_sketchlib::ExponentialHistogramUnivMon; + use asap_sketchlib::analytics::{EhUnivMonData, EhUnivMonParams, FlowData, UnivMonParams}; + + type Ehu = ExponentialHistogramUnivMon; + + fn params(k: usize, window: u64, rows: usize, cols: usize, layers: usize) -> EhUnivMonParams { + EhUnivMonParams { + k, + window, + univmon: UnivMonParams { + heap_size: 8, + rows, + cols, + layers, + }, + pool_initial_size: 2, + } + } + + fn build(p: &EhUnivMonParams) -> Ehu { + let u = p.univmon; + Ehu::with_pool_initial_size( + p.k, + p.window, + u.heap_size, + u.rows, + u.cols, + u.layers, + p.pool_initial_size, + ) + } + + /// The most UnivMon buckets, exact buckets and exact keys seen. + fn observe(eh: &Ehu, most: &mut EhUnivMonData) { + let keys: usize = eh.exact_buckets.iter().map(|b| b.counts.len()).sum(); + let max = |a: Option, b: usize| Some(a.unwrap_or(0).max(b)); + most.sketch_buckets = max(most.sketch_buckets, eh.sketch_buckets.len()); + most.exact_buckets = max(most.exact_buckets, eh.exact_buckets.len()); + most.exact_keys = max(most.exact_keys, keys); + } + + const SIZES: [(usize, usize, usize, usize); 2] = [(4, 2, 8, 2), (8, 2, 16, 2)]; + + #[test] + fn eh_univmon_memory_bounds_memory_bytes() { + for (i, (k, rows, cols, layers)) in SIZES.into_iter().enumerate() { + let p = params(k, 1500, rows, cols, layers); + let mut eh = build(&p); + let mut most = EhUnivMonData { + key_bytes: Some(0), + flow: FlowData { + key_bytes: Some(0), + ..FlowData::default() + }, + ..EhUnivMonData::default() + }; + let mut rng = rng(111, i as u64); + for t in 0..3000 { + eh.update_weighted(t, &DataInput::U64(rng.random_range(0..400)), 1); + observe(&eh, &mut most); + } + let theory = Ehu::theoretical_memory_bytes(&p, &most).unwrap(); + for unknown in [ + EhUnivMonData { + key_bytes: None, + ..most + }, + EhUnivMonData { + flow: FlowData::default(), + ..most + }, + ] { + assert_eq!(Ehu::theoretical_memory_bytes(&p, &unknown), None); + } + assert!( + theory >= eh.memory_bytes(), + "{theory} < {}", + eh.memory_bytes() + ); + assert!( + most.sketch_buckets.unwrap() > 0, + "no bucket reached UnivMon" + ); + } + } + + #[test] + fn eh_univmon_exact_l2_is_within_relative_epsilon_on_every_trial() { + let p = params(200, 500, 3, 1024, 4); + let bound = Ehu::theoretical_error( + StatisticKind::FrequencyL2, + &p, + &EhUnivMonData { + exact_query: Some(true), + ..EhUnivMonData::default() + }, + None, + ) + .unwrap(); + assert_eq!(bound.delta, Some(0.0)); + for trial in 0..60 { + let mut rng = rng(112, trial); + let mut eh = build(&p); + let end = p.window + rng.random_range(0..800); + let mut keys = Vec::new(); + for t in 0..=end { + let key = rng.random_range(0..50u64); + keys.push(key); + eh.update_weighted(t, &DataInput::U64(key), 1); + } + assert!(eh.sketch_buckets.is_empty()); + let start = end - p.window; + let mut counts = std::collections::HashMap::new(); + for &key in &keys[start as usize..] { + *counts.entry(key).or_insert(0u64) += 1; + } + let truth = counts.values().map(|&c| (c * c) as f64).sum::().sqrt(); + let estimate = eh.query_interval(start, end).unwrap().estimate_l2(); + assert!( + (estimate - truth).abs() <= bound.epsilon * truth, + "trial {trial}: {estimate} vs {truth}, eps={}", + bound.epsilon + ); + } + } + + #[cfg(feature = "op-count")] + #[test] + fn eh_univmon_ops_are_within_their_formulas() { + use asap_sketchlib::analytics::ops; + for (i, (k, rows, cols, layers)) in SIZES.into_iter().enumerate() { + let p = params(k, 1500, rows, cols, layers); + let mut eh = build(&p); + let mut most = EhUnivMonData { + key_bytes: Some(0), + flow: FlowData { + key_bytes: Some(0), + ..FlowData::default() + }, + ..EhUnivMonData::default() + }; + let mut rng = rng(113, i as u64); + let updates = 3000; + let mut counted = 0; + for t in 0..updates { + let key = DataInput::U64(rng.random_range(0..400)); + let ((), ops) = ops::count(|| eh.update_weighted(t, &key, 1)); + counted += ops; + observe(&eh, &mut most); + } + let average = counted as f64 / updates as f64; + let bound = Ehu::theoretical_update_ops(&p, &most); + assert!( + average <= bound, + "update averages {average}, formula {bound}" + ); + assert!( + most.sketch_buckets.unwrap() > 0, + "no bucket reached UnivMon" + ); + let query = Ehu::theoretical_query_ops(StatisticKind::FrequencyL2, &p, &most).unwrap(); + for start in [0, updates / 2, updates - 5] { + assert_ops_within("query", query, || { + eh.query_interval(start, updates - 1).unwrap().estimate_l2() + }); + } + } + } +} + +// --------------------------------------------------------------------------- +// Octo +// --------------------------------------------------------------------------- + +#[cfg(feature = "octo-runtime")] +mod octo { + use super::*; + use asap_sketchlib::analytics::{OctoData, OctoParent, frameworks}; + use asap_sketchlib::{ + CountMinSketchOctoPlan, OctoAggregator, OctoConfig, OctoPartition, OctoPlan, OctoWorker, + hash64_seeded, + }; + + fn config(workers: usize, tau: u32, partition: OctoPartition) -> OctoConfig { + OctoConfig { + num_workers: workers, + threshold: asap_sketchlib::OctoThreshold::new(tau), + partition, + ..OctoConfig::default() + } + } + + /// Workers and parent run on this thread, each delta applied as it is + /// emitted: the state after the queues drain, before a flush. + #[test] + fn octo_count_min_residual_is_within_epsilon_with_probability_one_minus_delta() { + let cell = MatrixParams { rows: 3, cols: 256 }; + let (workers, tau) = (4, 60); + let cfg = config(workers, tau, OctoPartition::HashByKey); + let trials = 200; + let mut failures = 0; + let mut delta = 0.0; + for trial in 0..trials { + let mut rng = rng(114, trial); + let keys = mixed_keys(&mut rng, 20, 2000, 4000); + let data = OctoData { + total_weight: Some(l1(&keys) as u64), + ..OctoData::default() + }; + let bound = frameworks::octo_error( + StatisticKind::ItemFrequency, + &OctoParent::CountMin(cell), + &cfg, + &data, + None, + ) + .unwrap(); + delta = bound.delta.unwrap(); + let plan = + CountMinSketchOctoPlan::with_threshold(cell.rows, cell.cols, cfg.threshold.clone()); + let mut fleet: Vec<_> = (0..workers).map(|id| plan.worker(id)).collect(); + let mut parent = plan.aggregator(); + for &(id, w) in &keys { + let input = DataInput::U64(id); + let worker = (hash64_seeded(0, &input) % workers as u64) as usize; + let payload = plan.prepare(&input); + for _ in 0..w { + fleet[worker].process(&payload, &mut |d| parent.apply(d)); + } + } + let (target, truth) = keys[(trial % 3) as usize]; + let estimate = parent.sketch.estimate(&DataInput::U64(target)) as f64; + if (estimate - truth as f64).abs() > bound.epsilon { + failures += 1; + } + } + assert!( + failures <= allowed_failures(trials as usize, delta), + "{failures}/{trials} beyond the residual bound at delta={delta}" + ); + } + + #[test] + fn octo_max_threshold_is_the_largest_a_worker_holds() { + let adaptive = |min_threshold, max_threshold| asap_sketchlib::OctoAdaptiveThreshold { + min_threshold, + max_threshold, + ..asap_sketchlib::OctoAdaptiveThreshold::default() + }; + let with = |tau, band| OctoConfig { + adaptive: Some(band), + ..config(4, tau, OctoPartition::HashByKey) + }; + assert_eq!( + frameworks::octo_max_threshold(&with(31, adaptive(1, 8))), + 31 + ); + assert_eq!(frameworks::octo_max_threshold(&with(4, adaptive(1, 8))), 8); + assert_eq!( + frameworks::octo_max_threshold(&with(4, adaptive(20, 8))), + 20 + ); + assert_eq!( + frameworks::octo_max_threshold(&with(4, adaptive(1, 500))), + 127 + ); + assert_eq!( + frameworks::octo_max_threshold(&config(4, 31, OctoPartition::HashByKey)), + 31 + ); + } + + #[test] + fn octo_min_threshold_is_the_smallest_a_worker_holds() { + let adaptive = |min_threshold| asap_sketchlib::OctoAdaptiveThreshold { + min_threshold, + ..asap_sketchlib::OctoAdaptiveThreshold::default() + }; + let with = |tau, floor| OctoConfig { + adaptive: Some(adaptive(floor)), + ..config(4, tau, OctoPartition::HashByKey) + }; + let parent_writes = |cfg: &OctoConfig| frameworks::octo_update_ops(3, cfg).1; + assert_eq!(frameworks::octo_min_threshold(&with(2, 16)), 2); + assert_eq!(parent_writes(&with(2, 16)), 1.5); + assert_eq!(frameworks::octo_min_threshold(&with(31, 16)), 16); + assert_eq!(frameworks::octo_min_threshold(&with(31, 0)), 1); + assert_eq!(frameworks::octo_min_threshold(&with(200, 300)), 127); + assert_eq!( + frameworks::octo_min_threshold(&config(4, 31, OctoPartition::HashByKey)), + 31 + ); + } + + #[test] + fn octo_bounds_follow_the_partition_and_the_flush() { + let cell = MatrixParams { rows: 3, cols: 256 }; + let data = OctoData { + total_weight: Some(10_000), + l2: Some(500.0), + ..OctoData::default() + }; + let read = |parent: OctoParent, cfg: &OctoConfig, data: &OctoData| { + frameworks::octo_error(StatisticKind::ItemFrequency, &parent, cfg, data, None) + }; + let by_key = config(4, 60, OctoPartition::HashByKey); + let round_robin = config(4, 60, OctoPartition::RoundRobin); + let cm = read(OctoParent::CountMin(cell), &by_key, &data).unwrap(); + let cm_rr = read(OctoParent::CountMin(cell), &round_robin, &data).unwrap(); + assert!((cm_rr.epsilon - cm.epsilon - 3.0 * 59.0).abs() < 1e-9); + let cs = read(OctoParent::CountSketch(cell), &by_key, &data).unwrap(); + let cs_rr = read(OctoParent::CountSketch(cell), &round_robin, &data).unwrap(); + assert_eq!(cs.epsilon, cs_rr.epsilon); + let flushed = OctoData { + flushed: true, + ..data + }; + assert_eq!( + read(OctoParent::CountMin(cell), &by_key, &flushed).unwrap().epsilon, + asap_sketchlib::CountMinSketch::, asap_sketchlib::RegularPath>::theoretical_error( + StatisticKind::ItemFrequency, + &cell, + &(), + None + ) + .unwrap() + .epsilon + ); + let heap = read(OctoParent::CountMinWithHeap(cell), &by_key, &flushed).unwrap(); + assert_eq!(heap.epsilon, cm.epsilon); + assert!(read(OctoParent::Other, &by_key, &flushed).is_none()); + let memory = frameworks::octo_memory_bytes::(&by_key, 1000, 768); + assert!(memory > 1000 + 4 * 768); + } +} diff --git a/tests/analytics/heavy_hitters.rs b/tests/analytics/heavy_hitters.rs new file mode 100644 index 00000000..4ff849ef --- /dev/null +++ b/tests/analytics/heavy_hitters.rs @@ -0,0 +1,639 @@ +//! SpaceSaving, ElasticSketch and CocoSketch. + +use crate::support::*; +use asap_sketchlib::analytics::{ + CocoParams, ElasticParams, FlowData, SpaceSavingData, SpaceSavingParams, +}; +use asap_sketchlib::{ + CocoSketch, DataInput, ElasticSketch, ErrorMetric, SketchAnalytics, SpaceSaving, StatisticKind, +}; +use rand::seq::SliceRandom; +use std::collections::HashMap; + +type Ss = SpaceSaving; +type Elastic = ElasticSketch; +type Coco = CocoSketch; + +const UNMERGED: FlowData = FlowData { + distinct: None, + key_bytes: None, + merged: Some(false), +}; + +const UNMERGED_SS: SpaceSavingData = SpaceSavingData { + key_bytes: None, + top_k: None, + other_capacity: None, + merged: Some(false), +}; + +const SS_SIZES: [SpaceSavingParams; 2] = [ + SpaceSavingParams { capacity: 16 }, + SpaceSavingParams { capacity: 256 }, +]; + +const ELASTIC_SIZES: [ElasticParams; 2] = [ + ElasticParams { + bucket_count: 16, + light_rows: 2, + light_cols: 64, + }, + ElasticParams { + bucket_count: 64, + light_rows: 4, + light_cols: 256, + }, +]; + +const COCO_SIZES: [CocoParams; 2] = [ + CocoParams { rows: 2, cols: 16 }, + CocoParams { rows: 4, cols: 128 }, +]; + +fn key(id: u64) -> DataInput<'static> { + DataInput::U64(id) +} + +/// A 16-byte flow id that owns exactly its length. +fn flow(id: u64) -> String { + let mut id = format!("{id:016x}"); + id.shrink_to_fit(); + id +} + +fn elastic(p: ElasticParams) -> Elastic { + Elastic::with_heavy_and_light(p.bucket_count as i32, p.light_rows, p.light_cols) +} + +/// `keys` as a shuffled stream of unit updates. +fn unit_stream(rng: &mut rand::rngs::StdRng, keys: &[(u64, u64)]) -> Vec { + let mut stream: Vec = keys + .iter() + .flat_map(|&(id, w)| std::iter::repeat_n(id, w as usize)) + .collect(); + stream.shuffle(rng); + stream +} + +#[test] +fn space_saving_memory_covers_the_summary() { + for p in SS_SIZES { + let mut numbers = Ss::with_capacity(p.capacity); + let mut strings = Ss::with_capacity(p.capacity); + let mut rng = rng(10, p.capacity as u64); + let keys = mixed_keys(&mut rng, 40, 64, 4000); + for id in unit_stream(&mut rng, &keys) { + numbers.update(&key(id)); + strings.update(&DataInput::String(flow(id))); + } + let other = numbers.clone(); + numbers.merge(&other); + let unknown = ::theoretical_memory_bytes(&p, &Default::default()); + assert_eq!(unknown, None); + let data = SpaceSavingData { + key_bytes: Some(0), + top_k: None, + ..SpaceSavingData::default() + }; + let bound = ::theoretical_memory_bytes(&p, &data).unwrap(); + assert!( + bound >= numbers.memory_bytes(), + "{bound} < {}", + numbers.memory_bytes() + ); + let data = SpaceSavingData { + key_bytes: Some(16), + top_k: None, + ..SpaceSavingData::default() + }; + let bound = ::theoretical_memory_bytes(&p, &data).unwrap(); + assert!( + bound >= strings.memory_bytes(), + "{bound} < {}", + strings.memory_bytes() + ); + } +} + +#[test] +fn elastic_memory_is_exact_once_every_bucket_holds_a_flow() { + for p in ELASTIC_SIZES { + let mut sketch = elastic(p); + let data = FlowData { + distinct: None, + key_bytes: Some(16), + merged: None, + }; + let bound = ::theoretical_memory_bytes(&p, &data).unwrap(); + assert!(bound >= sketch.memory_bytes()); + for id in 0..4000 { + sketch.update(flow(id % 700)); + } + assert!(sketch.heavy.iter().all(|b| !b.is_vacant())); + assert_eq!(bound, sketch.memory_bytes()); + let unknown = FlowData { + key_bytes: None, + ..data + }; + assert_eq!( + ::theoretical_memory_bytes(&p, &unknown), + None + ); + } +} + +#[test] +fn elastic_memory_counts_the_capacity_of_each_flow_id() { + let key = |id: u64| format!("flow-{id}"); + let capacity = key(7).capacity(); + assert!(capacity > key(7).len()); + assert!((0..700).all(|id| key(id).capacity() == capacity)); + for p in ELASTIC_SIZES { + let mut sketch = elastic(p); + for id in 0..4000 { + sketch.update(key(id % 700)); + } + assert!(sketch.heavy.iter().all(|b| !b.is_vacant())); + let data = FlowData { + distinct: None, + key_bytes: Some(capacity), + merged: None, + }; + let bound = ::theoretical_memory_bytes(&p, &data); + assert_eq!(bound, Some(sketch.memory_bytes())); + } +} + +#[test] +fn coco_memory_is_exact_once_every_bucket_holds_a_key() { + for p in COCO_SIZES { + let mut sketch = Coco::with_dimensions_and_seed(p.rows, p.cols, 3); + for id in 0..300 { + sketch.update_weighted(&flow(id), 1); + } + let data = FlowData { + distinct: Some(300), + key_bytes: Some(16), + merged: None, + }; + let bound = ::theoretical_memory_bytes(&p, &data).unwrap(); + assert!(bound >= sketch.memory_bytes()); + for id in 0..20_000 { + sketch.update_weighted(&flow(id), 1); + } + assert_eq!(sketch.recorded_flows().count(), p.rows * p.cols); + let data = FlowData { + distinct: None, + key_bytes: Some(16), + merged: None, + }; + let bound = ::theoretical_memory_bytes(&p, &data); + assert_eq!(bound, Some(sketch.memory_bytes())); + let unknown = FlowData { + key_bytes: None, + ..data + }; + assert_eq!( + ::theoretical_memory_bytes(&p, &unknown), + None + ); + } +} + +#[test] +fn space_saving_is_off_by_at_most_n_over_m_on_every_trial() { + let p = SpaceSavingParams { capacity: 64 }; + let data = UNMERGED_SS; + let bound = Ss::theoretical_error(StatisticKind::ItemFrequency, &p, &data, None).unwrap(); + assert_eq!(bound.metric, ErrorMetric::L1); + assert_eq!(bound.delta, Some(0.0)); + let top = Ss::theoretical_error(StatisticKind::TopK, &p, &data, Some(0.01)).unwrap(); + assert_eq!(top, bound); + for trial in 0..100 { + let mut rng = rng(11, trial); + let keys = mixed_keys(&mut rng, 50, 64, 3000); + let mut sketch = Ss::with_capacity(p.capacity); + for id in unit_stream(&mut rng, &keys) { + sketch.update(&key(id)); + } + let allowed = bound.epsilon * l1(&keys); + for &(id, truth) in &keys { + let estimate = sketch.estimate(&key(id)); + assert!( + (estimate as f64 - truth as f64).abs() <= allowed, + "trial {trial}: key {id} estimated {estimate}, true {truth}, allowed {allowed}" + ); + } + let truth: HashMap = keys.iter().copied().collect(); + for (reported, count, _) in sketch.top_k(10) { + let asap_sketchlib::OwnedDataInput::U64(id) = reported else { + panic!("not a u64 key"); + }; + assert!((count as f64 - truth[&id] as f64).abs() <= allowed); + } + } +} + +#[test] +fn elastic_overcounts_by_at_most_epsilon_n_with_probability_one_minus_delta() { + let p = ElasticParams { + bucket_count: 16, + light_rows: 3, + light_cols: 64, + }; + let bound = + Elastic::theoretical_error(StatisticKind::ItemFrequency, &p, &UNMERGED, None).unwrap(); + assert_eq!(bound.metric, ErrorMetric::L1); + let delta = bound.delta.unwrap(); + assert!((delta - (-3.0f64).exp()).abs() < 1e-12); + let trials = 600; + let mut failures = 0; + for trial in 0..trials { + let mut rng = rng(12, trial); + let keys = mixed_keys(&mut rng, 50, 64, 3000); + let ids: HashMap = keys.iter().map(|&(id, _)| (id, flow(id))).collect(); + let mut sketch = elastic(p); + for id in unit_stream(&mut rng, &keys) { + sketch.update(ids[&id].clone()); + } + let truths: HashMap<&str, u64> = + keys.iter().map(|&(id, w)| (ids[&id].as_str(), w)).collect(); + for bucket in sketch + .heavy + .iter() + .filter(|b| !b.is_vacant() && !b.eviction) + { + assert_eq!( + sketch.estimate(bucket.flow_id.clone()) as u64, + truths[bucket.flow_id.as_str()], + "a never-evicted resident is counted exactly" + ); + } + let (target, truth) = if trial % 2 == 0 { + keys[(trial / 2 % 50) as usize] + } else { + keys[50 + (trial as usize % 3000)] + }; + let estimate = sketch.estimate(ids[&target].clone()) as i64; + assert!(estimate >= truth as i64, "Elastic undercounted"); + if (estimate - truth as i64) as f64 > bound.epsilon * l1(&keys) { + failures += 1; + } + } + assert!( + failures <= allowed_failures(trials as usize, delta), + "{failures}/{trials} beyond eps={} at delta={delta}", + bound.epsilon + ); +} + +#[test] +fn elastic_never_undercounts_any_key() { + // Few heavy buckets force evictions, and a wide light layer leaves little + // collision mass to hide a lost vote. + let p = ElasticParams { + bucket_count: 4, + light_rows: 3, + light_cols: 1 << 14, + }; + for trial in 0..20 { + let mut rng = rng(14, trial); + let keys = mixed_keys(&mut rng, 50, 64, 3000); + let ids: HashMap = keys.iter().map(|&(id, _)| (id, flow(id))).collect(); + let mut sketch = elastic(p); + for id in unit_stream(&mut rng, &keys) { + sketch.update(ids[&id].clone()); + } + for &(id, truth) in &keys { + let estimate = sketch.estimate(ids[&id].clone()) as i64; + assert!( + estimate >= truth as i64, + "trial {trial}: estimated {estimate}, true {truth}" + ); + } + } +} + +#[test] +fn space_saving_sizing_meets_the_target_and_one_counter_fewer_does_not() { + let data = UNMERGED_SS; + for epsilon in [0.1, 0.01, 0.003] { + let p = Ss::params_for(StatisticKind::ItemFrequency, epsilon, 0.0, &data).unwrap(); + let bound = + Ss::theoretical_error(StatisticKind::ItemFrequency, &p, &data, Some(0.0)).unwrap(); + assert!(bound.epsilon <= epsilon); + let fewer = SpaceSavingParams { + capacity: p.capacity - 1, + }; + let wider = Ss::theoretical_error(StatisticKind::ItemFrequency, &fewer, &data, None); + assert!( + wider.unwrap().epsilon > epsilon, + "{p:?} is larger than needed" + ); + } +} + +#[test] +fn elastic_sizing_meets_the_target_and_one_step_smaller_does_not() { + let data = UNMERGED; + for (epsilon, delta) in [(0.1, 0.1), (0.01, 0.01), (0.002, 0.007)] { + let p = Elastic::params_for(StatisticKind::ItemFrequency, epsilon, delta, &data).unwrap(); + let bound = + Elastic::theoretical_error(StatisticKind::ItemFrequency, &p, &data, Some(delta)) + .unwrap(); + assert!(bound.epsilon <= epsilon && bound.delta.unwrap() <= delta); + let narrower = ElasticParams { + light_cols: p.light_cols - 1, + ..p + }; + let wider = + Elastic::theoretical_error(StatisticKind::ItemFrequency, &narrower, &data, None); + assert!( + wider.unwrap().epsilon > epsilon, + "{p:?} is wider than needed" + ); + if p.light_rows > 1 { + let shallower = ElasticParams { + light_rows: p.light_rows - 1, + ..p + }; + assert!( + Elastic::theoretical_error( + StatisticKind::ItemFrequency, + &shallower, + &data, + Some(delta) + ) + .is_none(), + "{p:?} is deeper than needed" + ); + } + } + // Light rows past the fifth share the heavy part's seed. + assert!(Elastic::params_for(StatisticKind::ItemFrequency, 0.01, 1e-3, &data).is_none()); + let deep = ElasticParams { + light_rows: 8, + ..ElasticParams::default() + }; + let bound = + Elastic::theoretical_error(StatisticKind::ItemFrequency, &deep, &data, None).unwrap(); + assert!((bound.delta.unwrap() - (-5.0f64).exp()).abs() < 1e-12); + assert!( + Elastic::theoretical_error(StatisticKind::ItemFrequency, &deep, &data, Some(1e-3)) + .is_none() + ); +} + +#[test] +fn elastic_top_k_takes_delta_over_every_distinct_key() { + assert!( + Elastic::theoretical_error( + StatisticKind::TopK, + &ElasticParams::default(), + &UNMERGED, + None + ) + .is_none() + ); + let data = FlowData { + distinct: Some(20), + key_bytes: None, + merged: Some(false), + }; + let p = Elastic::params_for(StatisticKind::TopK, 0.01, 0.2, &data).unwrap(); + assert_eq!(p.light_rows, 5); + let bound = Elastic::theoretical_error(StatisticKind::TopK, &p, &data, Some(0.2)).unwrap(); + assert!((bound.delta.unwrap() - 20.0 * (-5.0f64).exp()).abs() < 1e-12); + assert!(bound.epsilon <= 0.01); + assert!(Elastic::params_for(StatisticKind::TopK, 0.01, 0.1, &data).is_none()); +} + +#[test] +fn space_saving_and_elastic_bounds_need_an_unmerged_state() { + let ss = SpaceSavingParams::default(); + let flows = FlowData { + distinct: Some(20), + ..UNMERGED + }; + for statistic in [StatisticKind::ItemFrequency, StatisticKind::TopK] { + let elastic = Elastic::params_for(statistic, 0.01, 0.2, &flows).unwrap(); + assert!(Ss::theoretical_error(statistic, &ss, &UNMERGED_SS, None).is_some()); + assert!(Ss::params_for(statistic, 0.01, 0.0, &UNMERGED_SS).is_some()); + assert!(Elastic::theoretical_error(statistic, &elastic, &flows, Some(0.5)).is_some()); + assert!(Elastic::params_for(statistic, 0.01, 0.2, &flows).is_some()); + for merged in [None, Some(true)] { + let data = SpaceSavingData { + merged, + ..UNMERGED_SS + }; + assert_eq!(Ss::theoretical_error(statistic, &ss, &data, None), None); + assert_eq!(Ss::params_for(statistic, 0.01, 0.0, &data), None); + let data = FlowData { merged, ..flows }; + assert_eq!( + Elastic::theoretical_error(statistic, &elastic, &data, Some(0.5)), + None + ); + assert_eq!(Elastic::params_for(statistic, 0.01, 0.2, &data), None); + } + } +} + +#[test] +fn unanswered_statistics_have_no_bound() { + let ss = SpaceSavingParams::default(); + let flows = UNMERGED; + for statistic in [StatisticKind::Total, StatisticKind::Quantile] { + assert_eq!( + Ss::theoretical_error(statistic, &ss, &UNMERGED_SS, None), + None + ); + assert_eq!( + Ss::theoretical_query_ops(statistic, &ss, &Default::default()), + None + ); + assert_eq!( + Coco::theoretical_query_ops(statistic, &CocoParams::default(), &flows), + None + ); + } + for statistic in [ + StatisticKind::ItemFrequency, + StatisticKind::PartialKeySum, + StatisticKind::Total, + ] { + assert_eq!( + Coco::theoretical_error(statistic, &CocoParams::default(), &flows, None), + None + ); + } + assert_eq!( + Elastic::theoretical_error( + StatisticKind::Total, + &ElasticParams::default(), + &flows, + None + ), + None + ); +} + +#[cfg(feature = "op-count")] +mod ops { + use super::*; + + #[test] + fn space_saving_summary() { + for p in SS_SIZES { + let data = SpaceSavingData { + key_bytes: None, + top_k: Some(p.capacity / 2), + other_capacity: Some(p.capacity), + merged: None, + }; + let mut rng = rng(13, p.capacity as u64); + let mut a = Ss::with_capacity(p.capacity); + let mut b = Ss::with_capacity(p.capacity); + let keys = mixed_keys(&mut rng, 30, 64, 2000); + for id in unit_stream(&mut rng, &keys) { + a.update(&key(id)); + b.update(&key(id ^ 1)); + } + let update = Ss::theoretical_update_ops(&p, &data); + let held = a.top_k(1)[0].0.clone(); + let asap_sketchlib::OwnedDataInput::U64(held) = held else { + unreachable!() + }; + assert_ops_within("held update", update, || a.update(&key(held))); + for id in 0..20 { + assert_ops_within("new update", update, || a.update(&key(u64::MAX - id))); + } + let query = Ss::theoretical_query_ops(StatisticKind::ItemFrequency, &p, &data).unwrap(); + assert_ops_within("query", query, || a.estimate(&key(held))); + let top = Ss::theoretical_query_ops(StatisticKind::TopK, &p, &data).unwrap(); + assert_ops_within("top_k", top, || a.top_k(p.capacity / 2)); + let all = Ss::theoretical_query_ops(StatisticKind::TopK, &p, &Default::default()); + assert_ops_within("top_k", all.unwrap(), || a.top_k(usize::MAX)); + let merge = Ss::theoretical_merge_ops(&p, &data).unwrap(); + assert_ops_within("merge", merge, || a.merge(&b)); + } + // The costliest unit update evicts a key alone in the lowest bucket + // into a new bucket taken from the free list. + let mut s = Ss::with_capacity(2); + for id in [0, 1, 1, 1] { + s.update(&key(id)); + } + let p = SpaceSavingParams { capacity: 2 }; + let ((), ops) = asap_sketchlib::analytics::ops::count(|| s.update(&key(2))); + assert_eq!( + ops as f64, + Ss::theoretical_update_ops(&p, &Default::default()) + ); + } + + /// A state of `capacity` holding `keys` keys at distinct counts. + fn filled(capacity: usize, keys: u64, offset: u64) -> Ss { + let mut s = Ss::with_capacity(capacity); + for id in 0..keys { + for _ in 0..=id { + s.update(&key(offset + id)); + } + } + s + } + + #[test] + fn space_saving_merge_covers_unequal_capacities() { + for (m, other) in [(16, 256), (256, 16)] { + let p = SpaceSavingParams { capacity: m }; + let data = SpaceSavingData { + other_capacity: Some(other), + ..SpaceSavingData::default() + }; + let equal = SpaceSavingData { + other_capacity: Some(m), + ..data + }; + let unknown = Ss::theoretical_merge_ops(&p, &SpaceSavingData::default()); + assert_eq!(unknown, Some(f64::INFINITY)); + let mut a = filled(m, m as u64, 0); + let b = filled(other, other as u64, 1 << 20); + let merge = Ss::theoretical_merge_ops(&p, &data).unwrap(); + let ((), ops) = asap_sketchlib::analytics::ops::count(|| a.merge(&b)); + assert!(ops as f64 <= merge, "merge: {ops} > {merge}"); + if other > m { + let equal = Ss::theoretical_merge_ops(&p, &equal).unwrap(); + assert!( + ops as f64 > equal, + "{ops} within the equal-capacity {equal}" + ); + } + } + } + + #[test] + fn space_saving_unit_update_after_a_merge() { + let p = SpaceSavingParams { capacity: 256 }; + let update = Ss::theoretical_update_ops(&p, &SpaceSavingData::default()); + // An under-full state that carries the full side's minimum. + let mut a = filled(256, 8, 0); + a.merge(&filled(16, 16, 1 << 20)); + assert!(a.len() < a.capacity() && a.min_count() > 0); + for id in 0..300 { + assert_ops_within("new key", update, || a.update(&key(u64::MAX - id))); + } + for id in 0..8 { + assert_ops_within("held key", update, || a.update(&key(id))); + } + } + + #[test] + fn elastic_sketch() { + for p in ELASTIC_SIZES { + let data = FlowData::default(); + let mut a = elastic(p); + let mut b = elastic(p); + for id in 0..3000 { + a.update(flow(id % 400)); + b.update(flow(id % 500 + 200)); + } + let update = Elastic::theoretical_update_ops(&p, &data); + for id in [3, 7, 450, 9999] { + assert_ops_within("update", update, || a.update(flow(id))); + } + let query = + Elastic::theoretical_query_ops(StatisticKind::ItemFrequency, &p, &data).unwrap(); + assert_ops_within("query", query, || a.estimate(flow(7))); + let top = Elastic::theoretical_query_ops(StatisticKind::TopK, &p, &data).unwrap(); + assert_ops_within("top_k", top, || a.top_k(5)); + let merge = Elastic::theoretical_merge_ops(&p, &data).unwrap(); + assert_ops_within("merge", merge, || a.merge(&b)); + } + } + + #[test] + fn coco_sketch() { + for p in COCO_SIZES { + let data = FlowData::default(); + let mut a = Coco::with_dimensions_and_seed(p.rows, p.cols, 1); + let mut b = Coco::with_dimensions_and_seed(p.rows, p.cols, 2); + for id in 0..2000 { + a.update_weighted(&flow(id % 300), 1); + b.update_weighted(&flow(id % 400 + 100), 1); + } + let update = Coco::theoretical_update_ops(&p, &data); + for id in [3, 7, 999] { + assert_ops_within("update", update, || a.update_weighted(&flow(id), 2)); + } + let query = + Coco::theoretical_query_ops(StatisticKind::ItemFrequency, &p, &data).unwrap(); + assert_ops_within("query", query, || a.estimate(&flow(7))); + let partial = + Coco::theoretical_query_ops(StatisticKind::PartialKeySum, &p, &data).unwrap(); + assert_ops_within("partial key", partial, || { + a.estimate_projected("0000", |k| &k[..4]) + }); + let merge = Coco::theoretical_merge_ops(&p, &data).unwrap(); + assert_ops_within("merge", merge, || a.merge(&b)); + } + } +} diff --git a/tests/analytics/main.rs b/tests/analytics/main.rs new file mode 100644 index 00000000..8bff8593 --- /dev/null +++ b/tests/analytics/main.rs @@ -0,0 +1,17 @@ +//! The analytics in `docs/sketch_analytics.md` against the sketches: memory +//! against `memory_bytes()`, op formulas against counted ops (`op-count` +//! feature), error bounds against failure rates over independent trials, and +//! sizing against its own error bound. +//! +//! One target, one file per family; what more than one family needs lives in +//! `support`. + +mod support; + +mod cardinality; +mod count_matrix; +mod frameworks; +mod heavy_hitters; +mod quantiles; +mod top_k; +mod univmon; diff --git a/tests/analytics/quantiles.rs b/tests/analytics/quantiles.rs new file mode 100644 index 00000000..4fd5ea85 --- /dev/null +++ b/tests/analytics/quantiles.rs @@ -0,0 +1,732 @@ +//! Kll, KllDynamic, DdSketch and UniformSampling. + +use crate::support::*; +use asap_sketchlib::analytics::{DdData, DdParams, KllParams, StreamData, normal_quantile}; +use asap_sketchlib::{ + DdSketch, ErrorBound, ErrorMetric, Kll, KllDynamic, SketchAnalytics, StatisticKind, +}; +use rand::Rng; +use rand::rngs::StdRng; + +/// What the tests need from Kll and KllDynamic. +trait KllLike: SketchAnalytics + Sized { + fn seeded(p: KllParams, seed: u64) -> Self; + fn add(&mut self, value: f64); + fn absorb(&mut self, other: &Self); + fn quantile_at(&self, q: f64) -> f64; + fn rank_of(&self, x: f64) -> usize; + /// `memory_bytes()`, after caching a CDF where the sketch keeps one. + fn bytes(&mut self) -> usize; +} + +impl KllLike for Kll { + fn seeded(p: KllParams, seed: u64) -> Self { + Kll::with_seed(p.k, p.m, seed) + } + fn add(&mut self, value: f64) { + self.update(&value); + } + fn absorb(&mut self, other: &Self) { + self.merge(other); + } + fn quantile_at(&self, q: f64) -> f64 { + self.quantile(q) + } + fn rank_of(&self, x: f64) -> usize { + self.rank(x) + } + fn bytes(&mut self) -> usize { + self.cdf_cached(); + self.memory_bytes() + } +} + +impl KllLike for KllDynamic { + fn seeded(p: KllParams, seed: u64) -> Self { + KllDynamic::with_seed(p.k, p.m, seed) + } + fn add(&mut self, value: f64) { + self.update(&value); + } + fn absorb(&mut self, other: &Self) { + self.merge(other); + } + fn quantile_at(&self, q: f64) -> f64 { + self.quantile(q) + } + fn rank_of(&self, x: f64) -> usize { + self.rank(x) + } + fn bytes(&mut self) -> usize { + self.memory_bytes() + } +} + +fn stream(n: usize) -> StreamData { + StreamData { n: Some(n as u64) } +} + +/// `n` fresh values in [0, 1), from a splitmix64 stream seeded by `rng`. +fn fresh_values(rng: &mut StdRng, n: usize) -> Vec { + let mut state: u64 = rng.random(); + (0..n) + .map(|_| { + state = state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + ((z ^ (z >> 31)) >> 11) as f64 / (1u64 << 53) as f64 + }) + .collect() +} + +/// A state over `values`: one sketch, or `parts` independently seeded +/// sketches over disjoint slices merged into the first. +fn kll_state(p: KllParams, rng: &mut StdRng, values: &[f64], parts: usize) -> S { + let mut states = values.chunks(values.len().div_ceil(parts)).map(|chunk| { + let mut sketch = S::seeded(p, rng.random()); + chunk.iter().for_each(|&v| sketch.add(v)); + sketch + }); + let mut merged = states.next().unwrap(); + states.for_each(|other| merged.absorb(&other)); + merged +} + +fn kll_memory_bounds_memory_bytes() { + for p in [ + KllParams { k: 64, m: 8 }, + KllParams { k: 200, m: 2 }, + KllParams { k: 30, m: 20 }, + ] { + for n in [0, 10, 300, 5000, 100_000] { + for parts in [1, 3] { + let values = fresh_values(&mut rng(30, n as u64), n); + let mut sketch: S = if n == 0 { + S::seeded(p, 1) + } else { + kll_state(p, &mut rng(31, n as u64), &values, parts) + }; + let held = sketch.bytes(); + assert!( + S::theoretical_memory_bytes(&p, &stream(n)).unwrap() >= held, + "{p:?} n={n} parts={parts}" + ); + assert!(S::theoretical_memory_bytes(&p, &StreamData::default()).unwrap() >= held); + } + } + } +} + +#[test] +fn kll_memory_bounds_memory_bytes_through_merges() { + kll_memory_bounds_memory_bytes::(); +} + +#[test] +fn kll_dynamic_memory_bounds_memory_bytes_through_merges() { + kll_memory_bounds_memory_bytes::(); +} + +/// Over `trials` fresh streams of `n` values, half of them merges of four +/// independently seeded states, counts the trials whose quantile at a +/// varying `φ` and whose rank of a varying `x` miss their bounds. +fn kll_failures(salt: u64, p: KllParams, n: usize, trials: u64) -> (usize, usize) { + let none = StreamData::default(); + let quantile = S::theoretical_error(StatisticKind::Quantile, &p, &none, None).unwrap(); + let rank = S::theoretical_error(StatisticKind::Rank, &p, &none, None).unwrap(); + assert_eq!(quantile.metric, ErrorMetric::Rank); + assert_eq!(rank.metric, ErrorMetric::L1); + let (mut quantile_failures, mut rank_failures) = (0, 0); + for trial in 0..trials { + let mut rng = rng(salt, trial); + let values = fresh_values(&mut rng, n); + let phi = rng.random_range(0.05..0.95); + let x: f64 = rng.random(); + let sketch: S = kll_state(p, &mut rng, &values, if trial % 2 == 0 { 1 } else { 4 }); + let at_or_below = |v: f64| values.iter().filter(|&&y| y <= v).count() as f64; + let returned = sketch.quantile_at(phi); + if (at_or_below(returned) / n as f64 - phi).abs() > quantile.epsilon { + quantile_failures += 1; + } + if (sketch.rank_of(x) as f64 - at_or_below(x)).abs() > rank.epsilon * n as f64 { + rank_failures += 1; + } + } + (quantile_failures, rank_failures) +} + +fn kll_is_within_epsilon_with_probability_one_minus_delta(salt: u64, k: usize) { + let trials = 1000; + let p = KllParams { k, m: 8 }; + let (quantile, rank) = kll_failures::(salt, p, 100_000, trials); + let allowed = allowed_failures(trials as usize, 0.01); + assert!( + quantile <= allowed, + "k={k}: {quantile} quantile failures, allowed {allowed}" + ); + assert!( + rank <= allowed, + "k={k}: {rank} rank failures, allowed {allowed}" + ); +} + +#[test] +fn kll_quantile_and_rank_at_k_64_are_within_epsilon_with_probability_one_minus_delta() { + kll_is_within_epsilon_with_probability_one_minus_delta::(40, 64); +} + +#[test] +fn kll_quantile_and_rank_at_k_200_are_within_epsilon_with_probability_one_minus_delta() { + kll_is_within_epsilon_with_probability_one_minus_delta::(41, 200); +} + +#[test] +fn kll_dynamic_quantile_and_rank_at_k_64_are_within_epsilon_with_probability_one_minus_delta() { + kll_is_within_epsilon_with_probability_one_minus_delta::(50, 64); +} + +#[test] +fn kll_dynamic_quantile_and_rank_at_k_200_are_within_epsilon_with_probability_one_minus_delta() { + kll_is_within_epsilon_with_probability_one_minus_delta::(51, 200); +} + +fn kll_bound(statistic: StatisticKind, p: KllParams, delta: f64) -> Option { + ::theoretical_error(statistic, &p, &StreamData::default(), Some(delta)) +} + +#[test] +fn kll_bounds_follow_the_fits_and_need_m_at_least_8() { + let none = StreamData::default(); + for (statistic, constant, power, contract, scaled) in [ + ( + StatisticKind::Quantile, + 2.296, + 0.9723, + "kll_quantile_empirical_99_v1", + "kll_quantile_empirical_99_delta_scaled_v1", + ), + ( + StatisticKind::Rank, + 2.07, + 0.9646, + "kll_rank_measured_99_v1", + "kll_rank_measured_99_delta_scaled_v1", + ), + ] { + let p = KllParams { k: 200, m: 8 }; + let at = |delta| kll_bound(statistic, p, delta).unwrap(); + let fit = constant / 200f64.powf(power); + let default = + ::theoretical_error(statistic, &p, &none, None).unwrap(); + assert_eq!(default, at(0.01)); + assert!((default.epsilon - fit).abs() < 1e-12); + assert_eq!(default.contract, contract); + assert_eq!(at(0.05).epsilon, default.epsilon); + assert_eq!(at(0.05).delta, Some(0.05)); + let tight = at(0.001); + assert_eq!(tight.contract, scaled); + let s = normal_quantile(1.0 - 0.0005) / normal_quantile(0.995); + assert!((tight.epsilon - s * fit).abs() < 1e-12); + assert_eq!( + ::theoretical_error(statistic, &p, &none, Some(0.001)), + Some(tight) + ); + for m in [1, 2, 7] { + assert_eq!(kll_bound(statistic, KllParams { k: 200, m }, 0.01), None); + } + assert_eq!( + kll_bound(statistic, KllParams { k: 3, m: 9 }, 0.01).is_some(), + statistic == StatisticKind::Quantile + ); + assert!(kll_bound(statistic, KllParams { k: 5, m: 40_000 }, 0.01).is_some()); + assert_eq!(kll_bound(statistic, p, 0.0), None); + } + assert_eq!( + kll_bound(StatisticKind::Distinct, KllParams::default(), 0.01), + None + ); + // The Rank fit was measured for k from 32. + assert_eq!( + kll_bound(StatisticKind::Rank, KllParams { k: 31, m: 8 }, 0.01), + None + ); + assert!(kll_bound(StatisticKind::Rank, KllParams { k: 32, m: 8 }, 0.01).is_some()); + // s(1e-20) = z(1 − 5e-21)/z(0.995), with z(1 − 5e-21) = 9.336045. + let s = asap_sketchlib::analytics::quantiles::kll_delta_scale(1e-20); + assert!((s - 9.336_045 / 2.575_829).abs() < 1e-5, "{s}"); +} + +#[test] +fn kll_sizing_meets_the_target_and_one_smaller_k_does_not() { + let none = StreamData::default(); + for statistic in [StatisticKind::Quantile, StatisticKind::Rank] { + for epsilon in [0.05, 0.01, 0.002] { + for delta in [0.05, 0.01, 1e-4] { + let p = + ::params_for(statistic, epsilon, delta, &none).unwrap(); + assert_eq!(p.m, 8); + assert!(kll_bound(statistic, p, delta).unwrap().epsilon <= epsilon); + let smaller = KllParams { k: p.k - 1, ..p }; + assert!( + kll_bound(statistic, smaller, delta).unwrap().epsilon > epsilon, + "{statistic:?} eps={epsilon} delta={delta} k={}", + p.k + ); + assert_eq!( + ::params_for(statistic, epsilon, delta, &none), + Some(p) + ); + } + } + assert_eq!( + ::params_for(statistic, 1e-5, 0.01, &none), + None + ); + } + let loose = ::params_for(StatisticKind::Rank, 0.5, 0.01, &none); + assert_eq!(loose, Some(KllParams { k: 32, m: 8 })); + assert!( + kll_bound(StatisticKind::Rank, loose.unwrap(), 0.01) + .unwrap() + .epsilon + <= 0.5 + ); +} + +/// `n` values with magnitudes log-uniform in `[10^lo, 10^hi)`, each negative +/// with probability `negative_share`. +fn dd_values(rng: &mut StdRng, n: usize, (lo, hi): (f64, f64), negative_share: f64) -> Vec { + (0..n) + .map(|_| { + let magnitude = 10f64.powf(rng.random_range(lo..hi)); + if rng.random_bool(negative_share) { + -magnitude + } else { + magnitude + } + }) + .collect() +} + +const WIDE: (f64, f64) = (-3.0, 6.0); + +fn dd_state(alpha: f64, values: &[f64], merged: bool) -> DdSketch { + let (left, right) = values.split_at(if merged { + values.len() / 3 + } else { + values.len() + }); + let mut sketch = DdSketch::new(alpha); + left.iter().for_each(|v| sketch.add(v)); + let mut other = DdSketch::new(alpha); + right.iter().for_each(|v| other.add(v)); + sketch.merge(&other).unwrap(); + sketch +} + +fn dd_data(values: &[f64]) -> DdData { + let magnitudes = || values.iter().map(|v| v.abs()); + DdData { + n: Some(values.len() as u64), + min_magnitude: Some(magnitudes().fold(f64::INFINITY, f64::min)), + max_magnitude: Some(magnitudes().fold(0.0, f64::max)), + negatives: Some(values.iter().any(|&v| v < 0.0)), + } +} + +const PHIS: [f64; 9] = [0.0, 0.01, 0.1, 0.25, 0.5, 0.75, 0.9, 0.99, 1.0]; + +/// δ = 0: every quantile of every trial is within `α` relative of the true +/// `⌈φ·n⌉`-th value. +#[test] +fn ddsketch_quantile_is_within_alpha_relative_on_every_trial() { + for trial in 0..200u64 { + let mut rng = rng(60, trial); + let alpha = if trial % 2 == 0 { 0.01 } else { 0.05 }; + let values = dd_values(&mut rng, 2000, WIDE, if trial % 4 < 2 { 0.0 } else { 0.3 }); + let p = DdParams { alpha }; + let bound = + DdSketch::theoretical_error(StatisticKind::Quantile, &p, &dd_data(&values), None) + .unwrap(); + assert_eq!( + (bound.metric, bound.delta), + (ErrorMetric::Relative, Some(0.0)) + ); + assert_eq!(bound.contract, "ddsketch_relative_error_alpha_v1"); + let sketch = dd_state(alpha, &values, trial % 3 == 0); + let mut sorted = values.clone(); + sorted.sort_by(f64::total_cmp); + let n = sorted.len(); + for phi in PHIS.into_iter().chain([rng.random()]) { + let truth = sorted[((phi * n as f64).ceil() as usize).clamp(1, n) - 1]; + let estimate = sketch.get_value_at_quantile(phi).unwrap(); + assert!( + (estimate - truth).abs() <= bound.epsilon * truth.abs() * (1.0 + 1e-9), + "trial {trial} phi {phi}: {estimate} vs {truth}" + ); + } + } +} + +/// δ = 0: every interpolated quantile is within `α` relative when the values +/// share a sign, and within `α·A` otherwise. +#[test] +fn ddsketch_interpolated_quantile_is_within_its_bound_on_every_trial() { + for trial in 0..200u64 { + let mut rng = rng(61, trial); + let alpha = if trial % 2 == 0 { 0.01 } else { 0.05 }; + let signed = trial % 4 >= 2; + // Signed magnitudes sit near `A`, where `α·A` is tight. + let values = if signed { + dd_values(&mut rng, 2000, (5.98, 6.0), 0.4) + } else { + dd_values(&mut rng, 2000, WIDE, 0.0) + }; + let data = dd_data(&values); + let bound = DdSketch::theoretical_error( + StatisticKind::QuantileInterpolated, + &DdParams { alpha }, + &data, + None, + ) + .unwrap(); + assert_eq!(bound.delta, Some(0.0)); + if signed { + assert_eq!(bound.metric, ErrorMetric::ABSOLUTE); + assert_eq!(bound.contract, "ddsketch_interpolated_absolute_alpha_v1"); + assert_eq!(bound.epsilon, alpha * data.max_magnitude.unwrap()); + } else { + assert_eq!(bound.metric, ErrorMetric::Relative); + assert_eq!(bound.contract, "ddsketch_interpolated_relative_alpha_v1"); + } + let sketch = dd_state(alpha, &values, trial % 3 == 0); + let mut sorted = values.clone(); + sorted.sort_by(f64::total_cmp); + let last = (sorted.len() - 1) as f64; + for phi in PHIS.into_iter().chain([rng.random()]) { + let rank = phi * last; + let (lower, upper) = (sorted[rank.floor() as usize], sorted[rank.ceil() as usize]); + let fraction = rank - rank.floor(); + let truth = lower * (1.0 - fraction) + upper * fraction; + let estimate = sketch.quantile_interpolated(phi).unwrap().unwrap(); + let allowed = if signed { + bound.epsilon + } else { + bound.epsilon * truth.abs() + }; + assert!( + (estimate - truth).abs() <= allowed * (1.0 + 1e-9), + "trial {trial} phi {phi}: {estimate} vs {truth}" + ); + } + } +} + +#[test] +fn ddsketch_interpolated_bound_needs_a_for_signed_or_unknown_values() { + let p = DdParams { alpha: 0.02 }; + let interpolated = |data: &DdData| { + DdSketch::theoretical_error(StatisticKind::QuantileInterpolated, &p, data, None) + }; + assert_eq!(interpolated(&DdData::default()), None); + let signed = DdData { + negatives: Some(true), + max_magnitude: Some(50.0), + ..DdData::default() + }; + assert_eq!(interpolated(&signed).unwrap().epsilon, 1.0); + assert_eq!( + DdSketch::theoretical_error(StatisticKind::Quantile, &p, &DdData::default(), Some(0.1)) + .unwrap() + .delta, + Some(0.0) + ); + for statistic in [ + StatisticKind::Total, + StatisticKind::Rank, + StatisticKind::Sample, + ] { + assert_eq!( + DdSketch::theoretical_error(statistic, &p, &signed, None), + None + ); + } +} + +#[test] +fn ddsketch_sizing_meets_the_target() { + let positive = DdData { + negatives: Some(false), + ..DdData::default() + }; + let signed = DdData { + negatives: Some(true), + max_magnitude: Some(200.0), + ..DdData::default() + }; + for (statistic, data) in [ + (StatisticKind::Quantile, DdData::default()), + (StatisticKind::QuantileInterpolated, positive), + (StatisticKind::QuantileInterpolated, signed), + ] { + for epsilon in [0.05, 0.01, 0.001] { + let p = DdSketch::params_for(statistic, epsilon, 0.0, &data).unwrap(); + let bound = DdSketch::theoretical_error(statistic, &p, &data, None).unwrap(); + assert!(bound.epsilon <= epsilon * (1.0 + 1e-12)); + assert!(bound.epsilon >= epsilon * (1.0 - 1e-12)); + } + } + for (statistic, epsilon, data) in [ + (StatisticKind::Quantile, 1.0, DdData::default()), + (StatisticKind::QuantileInterpolated, 300.0, signed), + ] { + let p = DdSketch::params_for(statistic, epsilon, 0.0, &data); + assert_eq!(p, Some(DdParams { alpha: 0.5 })); + let bound = DdSketch::theoretical_error(statistic, &p.unwrap(), &data, None).unwrap(); + assert!(bound.epsilon <= epsilon); + } + assert_eq!( + DdSketch::params_for(StatisticKind::Quantile, f64::NAN, 0.0, &DdData::default()), + None + ); + assert_eq!( + DdSketch::params_for( + StatisticKind::QuantileInterpolated, + 0.01, + 0.0, + &DdData::default() + ), + None + ); +} + +/// At `α = 1e−9` the indexable magnitudes are about 0.0371 to 26.98. +#[test] +fn ddsketch_bounds_need_alpha_in_range_and_indexable_magnitudes() { + let magnitudes = |min_magnitude, max_magnitude| DdData { + min_magnitude, + max_magnitude, + ..DdData::default() + }; + let fine = DdParams { alpha: 1e-9 }; + let error = |p: &DdParams, data: &DdData| { + DdSketch::theoretical_error(StatisticKind::Quantile, p, data, None) + }; + assert!(error(&fine, &magnitudes(Some(0.04), Some(26.0))).is_some()); + for data in [ + magnitudes(Some(0.03), Some(26.0)), + magnitudes(Some(0.04), Some(28.0)), + magnitudes(None, Some(-1.0)), + magnitudes(None, Some(f64::NAN)), + magnitudes(Some(f64::NAN), None), + ] { + assert_eq!(error(&fine, &data), None, "{data:?}"); + } + assert!( + DdSketch::params_for( + StatisticKind::Quantile, + 1e-9, + 0.0, + &magnitudes(None, Some(26.0)) + ) + .is_some() + ); + assert_eq!( + DdSketch::params_for( + StatisticKind::Quantile, + 1e-9, + 0.0, + &magnitudes(None, Some(28.0)) + ), + None + ); + for alpha in [0.0, 1.0, -0.5, f64::NAN] { + let p = DdParams { alpha }; + assert_eq!(error(&p, &DdData::default()), None); + assert_eq!( + ::theoretical_memory_bytes(&p, &DdData::default()), + None + ); + } +} + +#[test] +fn ddsketch_memory_bounds_memory_bytes() { + for alpha in [0.01, 0.05] { + for (n, negative_share) in [(1, 0.0), (500, 0.0), (5000, 0.3)] { + let values = dd_values(&mut rng(62, n as u64), n, WIDE, negative_share); + for merged in [false, true] { + let sketch = dd_state(alpha, &values, merged); + let p = DdParams { alpha }; + let known = + ::theoretical_memory_bytes(&p, &dd_data(&values)); + assert!(known.unwrap() >= sketch.memory_bytes()); + let unknown = + ::theoretical_memory_bytes(&p, &DdData::default()); + assert!(unknown.unwrap() >= sketch.memory_bytes()); + } + } + } +} + +#[cfg(feature = "op-count")] +mod ops { + use super::*; + + fn kll_ops() { + for p in [ + KllParams { k: 64, m: 8 }, + KllParams { k: 200, m: 8 }, + KllParams { k: 32, m: 2 }, + ] { + for n in [p.k / 2, 10 * p.k, 50_000] { + let data = stream(n); + let values = fresh_values(&mut rng(70, n as u64), n); + let mut a = S::seeded(p, 1); + let average = S::theoretical_update_ops(&p, &data); + assert_ops_within("updates", average * n as f64, || { + values.iter().for_each(|&v| a.add(v)) + }); + let unknown = S::theoretical_update_ops(&p, &StreamData::default()); + assert!(unknown >= average); + for statistic in [StatisticKind::Quantile, StatisticKind::Rank] { + let bound = S::theoretical_query_ops(statistic, &p, &data).unwrap(); + assert!( + S::theoretical_query_ops(statistic, &p, &StreamData::default()).unwrap() + >= bound + ); + assert_ops_within("query", bound, || match statistic { + StatisticKind::Quantile => a.quantile_at(0.3), + _ => a.rank_of(0.3) as f64, + }); + } + let mut b = S::seeded(p, 2); + values.iter().for_each(|&v| b.add(1.0 - v)); + let merge = S::theoretical_merge_ops(&p, &data).unwrap(); + assert_ops_within("merge", merge, || a.absorb(&b)); + } + } + } + + #[test] + fn kll() { + kll_ops::(); + } + + #[test] + fn kll_dynamic() { + kll_ops::(); + } + + #[test] + fn ddsketch() { + for alpha in [0.01, 0.05] { + let p = DdParams { alpha }; + let values = dd_values(&mut rng(71, 0), 5000, WIDE, 0.3); + let data = dd_data(&values); + let mut a = dd_state(alpha, &values[..2500], false); + let b = dd_state(alpha, &values[2500..], false); + let update = DdSketch::theoretical_update_ops(&p, &data); + for v in [1.0, -2.5, 0.0, 1e-300, 1e9, -1e-4] { + assert_ops_within("update", update, || a.add(&v)); + } + let mut a = dd_state(alpha, &values[..2500], false); + let quantile = DdSketch::theoretical_query_ops(StatisticKind::Quantile, &p, &data); + assert_ops_within("quantile", quantile.unwrap(), || { + a.get_value_at_quantile(1.0) + }); + let interpolated = + DdSketch::theoretical_query_ops(StatisticKind::QuantileInterpolated, &p, &data); + assert_ops_within("interpolated", interpolated.unwrap(), || { + a.quantile_interpolated(0.999_9).unwrap() + }); + let merge = DdSketch::theoretical_merge_ops(&p, &data).unwrap(); + assert_ops_within("merge", merge, || a.merge(&b).unwrap()); + let mut empty = DdSketch::new(alpha); + assert_ops_within("merge into empty", merge, || empty.merge(&b).unwrap()); + } + } +} + +#[cfg(feature = "experimental")] +mod uniform { + use super::*; + use asap_sketchlib::UniformSampling; + use asap_sketchlib::analytics::UniformParams; + + #[test] + fn memory_bounds_memory_bytes_through_merges() { + for sample_rate in [0.1, 0.5, 1.0] { + let p = UniformParams { sample_rate }; + for n in [1u64, 37, 1000, 20_000] { + let mut sampler = UniformSampling::with_seed(sample_rate, 3); + let mut other = UniformSampling::with_seed(sample_rate, 4); + for i in 0..n { + if i % 3 == 0 { + other.update(i as f64); + } else { + sampler.update(i as f64); + } + } + let alone = sampler.clone(); + sampler.merge(&other).unwrap(); + for (sampler, n) in [(sampler, n), (alone, n - n.div_ceil(3))] { + let bound = + UniformSampling::theoretical_memory_bytes(&p, &StreamData { n: Some(n) }); + assert!( + bound.unwrap() >= sampler.memory_bytes(), + "rho={sample_rate} n={n}" + ); + } + } + let unknown = UniformSampling::theoretical_memory_bytes(&p, &StreamData::default()); + assert_eq!(unknown, None); + } + } + + #[test] + fn sample_has_no_bound_and_total_is_not_a_readout() { + let p = UniformParams::default(); + let data = StreamData { n: Some(100) }; + for statistic in [StatisticKind::Sample, StatisticKind::Total] { + assert_eq!( + UniformSampling::theoretical_error(statistic, &p, &data, None), + None + ); + assert_eq!( + UniformSampling::params_for(statistic, 0.1, 0.01, &data), + None + ); + } + assert!(UniformSampling::theoretical_query_ops(StatisticKind::Sample, &p, &data).is_some()); + assert_eq!( + UniformSampling::theoretical_query_ops(StatisticKind::Total, &p, &data), + None + ); + } + + #[cfg(feature = "op-count")] + #[test] + fn ops() { + for sample_rate in [0.1, 0.5] { + let p = UniformParams { sample_rate }; + for n in [100u64, 3000] { + let mut a = UniformSampling::with_seed(sample_rate, 5); + let mut b = UniformSampling::with_seed(sample_rate, 6); + for i in 0..n { + let update = + UniformSampling::theoretical_update_ops(&p, &StreamData { n: Some(i) }); + assert_ops_within("update", update, || a.update(i as f64)); + b.update(-(i as f64)); + } + let data = StreamData { n: Some(n) }; + let sample = + UniformSampling::theoretical_query_ops(StatisticKind::Sample, &p, &data); + assert_ops_within("sample", sample.unwrap(), || a.samples()); + let merge = UniformSampling::theoretical_merge_ops(&p, &data).unwrap(); + assert_ops_within("merge", merge, || a.merge(&b).unwrap()); + } + } + } +} diff --git a/tests/analytics/support.rs b/tests/analytics/support.rs new file mode 100644 index 00000000..6daf0541 --- /dev/null +++ b/tests/analytics/support.rs @@ -0,0 +1,60 @@ +//! Shared helpers of the analytics tests. + +#![allow(dead_code)] + +use rand::rngs::StdRng; +use rand::{Rng, SeedableRng}; + +/// Failures allowed out of `trials` at a true rate of at most `delta`: the +/// mean plus four standard deviations, plus one. +pub fn allowed_failures(trials: usize, delta: f64) -> usize { + let n = trials as f64; + (n * delta + 4.0 * (n * delta * (1.0 - delta)).sqrt() + 1.0).floor() as usize +} + +/// A seeded generator for trial `trial` of a test named by `salt`. +pub fn rng(salt: u64, trial: u64) -> StdRng { + StdRng::seed_from_u64(salt.wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ trial) +} + +/// `distinct` fresh random keys, each with a Zipf-like weight `⌈s/rank⌉`, so +/// every trial hashes a different key set. +pub fn zipf_keys(rng: &mut StdRng, distinct: usize, scale: u64) -> Vec<(u64, u64)> { + (0..distinct) + .map(|rank| (rng.random(), scale.div_ceil(rank as u64 + 1))) + .collect() +} + +/// A Zipf head of `head` keys at scale `scale` over `tail` fresh unit-weight +/// keys, so collisions carry real mass. +pub fn mixed_keys(rng: &mut StdRng, head: usize, scale: u64, tail: usize) -> Vec<(u64, u64)> { + let mut keys = zipf_keys(rng, head, scale); + keys.extend((0..tail).map(|_| (rng.random(), 1))); + keys +} + +/// `N`, the total weight of `keys`. +pub fn l1(keys: &[(u64, u64)]) -> f64 { + keys.iter().map(|&(_, w)| w as f64).sum() +} + +/// `‖f‖₂` of `keys`. +pub fn l2(keys: &[(u64, u64)]) -> f64 { + keys.iter() + .map(|&(_, w)| (w as f64).powi(2)) + .sum::() + .sqrt() +} + +/// Asserts the ops `f` performs are at most `bound`. +#[cfg(feature = "op-count")] +#[track_caller] +pub fn assert_ops_within(what: &str, bound: f64, f: impl FnOnce() -> R) -> R { + assert!(bound.is_finite(), "{what}: formula is {bound}"); + let (result, ops) = asap_sketchlib::analytics::ops::count(f); + assert!( + ops as f64 <= bound, + "{what}: counted {ops} ops, formula says at most {bound}" + ); + result +} diff --git a/tests/analytics/top_k.rs b/tests/analytics/top_k.rs new file mode 100644 index 00000000..59862a74 --- /dev/null +++ b/tests/analytics/top_k.rs @@ -0,0 +1,1037 @@ +//! Count-Min and Count Sketch with a heap, with a Space-Saving summary, and +//! folded, and the `TopKHeap` they share. + +use crate::support::*; +use asap_sketchlib::analytics::{ + FlowData, FoldData, FoldParams, MatrixHeapParams, MatrixSpaceSavingParams, +}; +use asap_sketchlib::{ + CountMinSketchFold, CountMinSketchWithHeap, CountMinSketchWithSpaceSaving, CountSketchFold, + CountSketchWithHeap, CountSketchWithSpaceSaving, DataInput, ErrorBound, ErrorMetric, + OwnedDataInput, RegularPath, SketchAnalytics, StatisticKind, Vector2D, +}; +use rand::seq::SliceRandom; +use std::collections::HashMap; + +type CmHeap = CountMinSketchWithHeap, RegularPath>; +type CsHeap = CountSketchWithHeap, RegularPath>; +type CmSs = CountMinSketchWithSpaceSaving, RegularPath>; +type CsSs = CountSketchWithSpaceSaving, RegularPath>; +type CmFold = CountMinSketchFold; +type CsFold = CountSketchFold; + +fn key(id: u64) -> DataInput<'static> { + DataInput::U64(id) +} + +fn id_of(key: &OwnedDataInput) -> u64 { + match key { + OwnedDataInput::U64(id) => *id, + other => panic!("expected a U64 key, got {other:?}"), + } +} + +/// A 16-byte key that owns exactly its length. +fn flow(id: u64) -> String { + let mut id = format!("{id:016x}"); + id.shrink_to_fit(); + id +} + +/// `keys` as a shuffled stream of unit updates. +fn unit_stream(rng: &mut rand::rngs::StdRng, keys: &[(u64, u64)]) -> Vec { + let mut stream: Vec = keys + .iter() + .flat_map(|&(id, w)| std::iter::repeat_n(id, w as usize)) + .collect(); + stream.shuffle(rng); + stream +} + +/// One sketch of the family, driven the same way by every test. +trait TopK: SketchAnalytics + Clone { + fn build(params: &Self::Params) -> Self; + fn add(&mut self, key: &DataInput, weight: i64); + fn estimate(&self, id: u64) -> f64; + /// The reported keys with their counts. + fn reported(&self) -> Vec<(u64, f64)>; + fn merge(&mut self, other: &Self); +} + +macro_rules! with_heap { + ($t:ty) => { + impl TopK for $t { + fn build(p: &MatrixHeapParams) -> Self { + Self::new(p.rows, p.cols, p.heap_size) + } + fn add(&mut self, key: &DataInput, weight: i64) { + self.update_weighted(key, weight); + } + fn estimate(&self, id: u64) -> f64 { + <$t>::estimate(self, &key(id)) as f64 + } + fn reported(&self) -> Vec<(u64, f64)> { + let heap = self.heap().heap(); + heap.iter() + .map(|e| (id_of(&e.key), e.count as f64)) + .collect() + } + fn merge(&mut self, other: &Self) { + <$t>::merge(self, other); + } + } + }; +} +with_heap!(CmHeap); +with_heap!(CsHeap); + +macro_rules! with_space_saving { + ($t:ty) => { + impl TopK for $t { + fn build(p: &MatrixSpaceSavingParams) -> Self { + Self::new(p.rows, p.cols, p.capacity) + } + fn add(&mut self, key: &DataInput, weight: i64) { + self.update_weighted(key, weight); + } + fn estimate(&self, id: u64) -> f64 { + <$t>::estimate(self, &key(id)) as f64 + } + fn reported(&self) -> Vec<(u64, f64)> { + let top = self.top_k(usize::MAX); + top.iter().map(|(k, c)| (id_of(k), *c as f64)).collect() + } + fn merge(&mut self, other: &Self) { + <$t>::merge(self, other); + } + } + }; +} +with_space_saving!(CmSs); +with_space_saving!(CsSs); + +macro_rules! fold { + ($t:ty) => { + impl TopK for $t { + fn build(p: &FoldParams) -> Self { + Self::new(p.rows, p.full_cols, p.fold_level, p.heap_size) + } + fn add(&mut self, key: &DataInput, weight: i64) { + self.update_weighted(key, weight); + } + fn estimate(&self, id: u64) -> f64 { + <$t>::estimate(self, &key(id)) as f64 + } + fn reported(&self) -> Vec<(u64, f64)> { + let heap = self.heap().heap(); + heap.iter() + .map(|e| (id_of(&e.key), e.count as f64)) + .collect() + } + fn merge(&mut self, other: &Self) { + self.merge_same_level(other); + } + } + }; +} +fold!(CmFold); +fold!(CsFold); + +/// `E` and `S` of a fold's cells. +fn fold_shape(cells: &[asap_sketchlib::FoldCell]) -> (usize, usize) { + let most = cells.iter().map(|c| c.entry_count()).max().unwrap_or(0); + let collided = cells + .iter() + .map(|c| c.entry_count()) + .filter(|&n| n > 1) + .sum(); + (most, collided) +} + +fn fold_data(cells: &[asap_sketchlib::FoldCell], distinct: usize) -> FoldData { + let (most, collided) = fold_shape(cells); + FoldData { + distinct: Some(distinct as u64), + max_cell_entries: Some(most), + collided_entries: Some(collided), + key_bytes: None, + merged: None, + } +} + +/// `N` for an `Absolute(L1)` bound and `‖f‖₂` for `Absolute(L2)`. +fn scale(bound: &ErrorBound, keys: &[(u64, u64)]) -> f64 { + if bound.metric == ErrorMetric::L1 { + l1(keys) + } else { + assert_eq!(bound.metric, ErrorMetric::L2); + l2(keys) + } +} + +/// One ItemFrequency estimate of a head key per trial against `bound`; a +/// Count-Min base never undercounts. +fn item_frequency_trials(salt: u64, params: &T::Params, bound: ErrorBound) { + let one_sided = bound.metric == ErrorMetric::L1; + let delta = bound.delta.unwrap(); + let trials = 800; + let mut failures = 0; + for trial in 0..trials { + let mut rng = rng(salt, trial); + let keys = mixed_keys(&mut rng, 50, 2000, 3000); + let (target, truth) = keys[(trial % 10) as usize]; + let mut sketch = T::build(params); + for &(id, w) in &keys { + sketch.add(&key(id), w as i64); + } + let estimate = sketch.estimate(target); + assert!(!one_sided || estimate >= truth as f64, "undercounted"); + if (estimate - truth as f64).abs() > bound.epsilon * scale(&bound, &keys) { + failures += 1; + } + } + assert!( + failures <= allowed_failures(trials as usize, delta), + "{failures}/{trials} beyond eps={} at delta={delta}", + bound.epsilon + ); +} + +/// `D` of every TopK trial: 20 heavy keys over 180 unit keys. +const TOP_K_DISTINCT: u64 = 200; + +/// Every reported count of one state per trial, built from unit updates, +/// against `bound`; a Count-Min base never undercounts. +fn top_k_trials(salt: u64, params: &T::Params, bound: ErrorBound) { + let one_sided = bound.metric == ErrorMetric::L1; + let delta = bound.delta.unwrap(); + let trials = 300; + let mut failures = 0; + for trial in 0..trials { + let mut rng = rng(salt, trial); + let keys = mixed_keys(&mut rng, 20, 300, 180); + assert_eq!(keys.len() as u64, TOP_K_DISTINCT); + let truth: HashMap = keys.iter().copied().collect(); + let mut sketch = T::build(params); + for id in unit_stream(&mut rng, &keys) { + sketch.add(&key(id), 1); + } + let reported = sketch.reported(); + assert!(!reported.is_empty()); + let limit = bound.epsilon * scale(&bound, &keys); + let mut failed = false; + for (id, count) in reported { + let truth = truth[&id] as f64; + assert!(!one_sided || count >= truth, "undercounted"); + failed |= (count - truth).abs() > limit; + } + failures += failed as usize; + } + assert!( + failures <= allowed_failures(trials as usize, delta), + "{failures}/{trials} trials beyond eps={} at delta={delta}", + bound.epsilon + ); +} + +const TOP_K_FLOWS: FlowData = FlowData { + distinct: Some(TOP_K_DISTINCT), + key_bytes: None, + merged: None, +}; + +const TOP_K_FOLDS: FoldData = FoldData { + distinct: Some(TOP_K_DISTINCT), + max_cell_entries: None, + collided_entries: None, + key_bytes: None, + merged: Some(false), +}; + +const ITEM_HEAP: MatrixHeapParams = MatrixHeapParams { + rows: 3, + cols: 64, + heap_size: 16, +}; +const TOP_K_HEAP: MatrixHeapParams = MatrixHeapParams { + rows: 7, + cols: 256, + heap_size: 16, +}; +const ITEM_SS: MatrixSpaceSavingParams = MatrixSpaceSavingParams { + rows: 3, + cols: 64, + capacity: 16, +}; +const TOP_K_SS: MatrixSpaceSavingParams = MatrixSpaceSavingParams { + rows: 5, + cols: 256, + capacity: 16, +}; +const ITEM_FOLD: FoldParams = FoldParams { + rows: 3, + full_cols: 64, + fold_level: 3, + heap_size: 16, +}; +const TOP_K_FOLD: FoldParams = FoldParams { + rows: 7, + full_cols: 256, + fold_level: 3, + heap_size: 16, +}; + +#[test] +fn count_min_with_heap_estimates_as_count_min() { + let none = FlowData::default(); + let bound = CmHeap::theoretical_error(StatisticKind::ItemFrequency, &ITEM_HEAP, &none, None); + item_frequency_trials::(50, &ITEM_HEAP, bound.unwrap()); +} + +#[test] +fn count_sketch_with_heap_estimates_as_count_sketch() { + let p = MatrixHeapParams { + cols: 256, + ..ITEM_HEAP + }; + let none = FlowData::default(); + let bound = CsHeap::theoretical_error(StatisticKind::ItemFrequency, &p, &none, Some(0.05)); + item_frequency_trials::(51, &p, bound.unwrap()); +} + +#[test] +fn count_min_with_heap_reports_counts_within_the_top_k_bound() { + let p = TOP_K_HEAP; + let bound = CmHeap::theoretical_error(StatisticKind::TopK, &p, &TOP_K_FLOWS, None).unwrap(); + assert_eq!(bound.delta, Some(TOP_K_DISTINCT as f64 * (-7f64).exp())); + top_k_trials::(52, &p, bound); +} + +#[test] +fn count_sketch_with_heap_reports_counts_within_the_top_k_bound() { + let p = MatrixHeapParams { + cols: 1024, + ..TOP_K_HEAP + }; + let bound = CsHeap::theoretical_error(StatisticKind::TopK, &p, &TOP_K_FLOWS, Some(0.05)); + let bound = bound.unwrap(); + assert_eq!(bound.delta, Some(0.05)); + top_k_trials::(53, &p, bound); +} + +#[test] +fn count_min_with_space_saving_estimates_as_count_min() { + let none = FlowData::default(); + let bound = CmSs::theoretical_error(StatisticKind::ItemFrequency, &ITEM_SS, &none, None); + item_frequency_trials::(54, &ITEM_SS, bound.unwrap()); +} + +#[test] +fn count_sketch_with_space_saving_estimates_as_count_sketch() { + let p = MatrixSpaceSavingParams { + cols: 256, + ..ITEM_SS + }; + let none = FlowData::default(); + let bound = CsSs::theoretical_error(StatisticKind::ItemFrequency, &p, &none, Some(0.05)); + item_frequency_trials::(55, &p, bound.unwrap()); +} + +#[test] +fn count_min_with_space_saving_reports_counts_within_the_top_k_bound() { + let p = TOP_K_SS; + let bound = CmSs::theoretical_error(StatisticKind::TopK, &p, &TOP_K_FLOWS, None).unwrap(); + assert_eq!(bound.delta, Some(16.0 * (-5f64).exp())); + top_k_trials::(56, &p, bound); +} + +#[test] +fn count_sketch_with_space_saving_reports_counts_within_the_top_k_bound() { + let p = MatrixSpaceSavingParams { + cols: 1024, + ..TOP_K_SS + }; + let bound = CsSs::theoretical_error(StatisticKind::TopK, &p, &TOP_K_FLOWS, Some(0.05)); + top_k_trials::(57, &p, bound.unwrap()); +} + +#[test] +fn count_min_fold_estimates_as_count_min_at_full_width() { + let none = FoldData::default(); + let bound = CmFold::theoretical_error(StatisticKind::ItemFrequency, &ITEM_FOLD, &none, None); + item_frequency_trials::(58, &ITEM_FOLD, bound.unwrap()); +} + +#[test] +fn count_sketch_fold_estimates_as_count_sketch_at_full_width() { + let p = FoldParams { + full_cols: 256, + fold_level: 5, + ..ITEM_FOLD + }; + let none = FoldData::default(); + let bound = CsFold::theoretical_error(StatisticKind::ItemFrequency, &p, &none, Some(0.05)); + item_frequency_trials::(59, &p, bound.unwrap()); +} + +#[test] +fn count_min_fold_reports_counts_within_the_top_k_bound() { + let p = TOP_K_FOLD; + let bound = CmFold::theoretical_error(StatisticKind::TopK, &p, &TOP_K_FOLDS, None).unwrap(); + top_k_trials::(60, &p, bound); +} + +#[test] +fn count_sketch_fold_reports_counts_within_the_top_k_bound() { + let p = FoldParams { + full_cols: 1024, + ..TOP_K_FOLD + }; + let bound = CsFold::theoretical_error(StatisticKind::TopK, &p, &TOP_K_FOLDS, Some(0.05)); + top_k_trials::(61, &p, bound.unwrap()); +} + +#[test] +fn top_k_needs_d_and_a_count_min_product_below_one() { + let p = MatrixHeapParams::default(); + let none = FlowData::default(); + let top_k = StatisticKind::TopK; + assert!(CmHeap::theoretical_error(top_k, &p, &none, None).is_none()); + assert!(CsHeap::theoretical_error(top_k, &p, &none, None).is_none()); + assert!(CmHeap::params_for(top_k, 0.01, 0.01, &none).is_none()); + // D·e^−3 ≥ 1 at D = 21. + let many = FlowData { + distinct: Some(21), + key_bytes: None, + merged: None, + }; + assert!(CmHeap::theoretical_error(StatisticKind::TopK, &p, &many, None).is_none()); + // The Space-Saving variants multiply by min(m, D). + let p = MatrixSpaceSavingParams { + rows: 5, + cols: 64, + capacity: 8, + }; + let bound = CmSs::theoretical_error(StatisticKind::TopK, &p, &TOP_K_FLOWS, None).unwrap(); + assert_eq!(bound.delta, Some(8.0 * (-5f64).exp())); + // At most m keys are reported, so an unknown D counts as m. + assert_eq!( + CmSs::theoretical_error(StatisticKind::TopK, &p, &none, None), + Some(bound) + ); + assert_eq!( + CsSs::theoretical_error(top_k, &p, &none, Some(0.05)), + CsSs::theoretical_error(top_k, &p, &TOP_K_FLOWS, Some(0.05)) + ); + assert!(CsSs::theoretical_error(top_k, &p, &none, Some(0.05)).is_some()); + let at_m = FlowData { + distinct: Some(MatrixSpaceSavingParams::default().capacity as u64), + ..none + }; + assert_eq!( + CmSs::params_for(top_k, 0.05, 0.01, &none), + CmSs::params_for(top_k, 0.05, 0.01, &at_m) + ); + assert!(CmSs::params_for(top_k, 0.05, 0.01, &none).is_some()); +} + +#[test] +fn fold_top_k_needs_a_state_merge_same_level_has_not_merged_into() { + let p = TOP_K_FOLD; + let top_k = StatisticKind::TopK; + assert!(CmFold::theoretical_error(top_k, &p, &TOP_K_FOLDS, None).is_some()); + assert!(CsFold::params_for(top_k, 0.05, 0.05, &TOP_K_FOLDS).is_some()); + for merged in [None, Some(true)] { + let data = FoldData { + merged, + ..TOP_K_FOLDS + }; + assert_eq!(CmFold::theoretical_error(top_k, &p, &data, None), None); + assert_eq!( + CsFold::theoretical_error(top_k, &p, &data, Some(0.05)), + None + ); + assert_eq!(CmFold::params_for(top_k, 0.05, 0.05, &data), None); + assert_eq!(CsFold::params_for(top_k, 0.05, 0.05, &data), None); + let item = StatisticKind::ItemFrequency; + assert_eq!( + CmFold::theoretical_error(item, &p, &data, None), + CmFold::theoretical_error(item, &p, &TOP_K_FOLDS, None) + ); + assert!(CmFold::params_for(item, 0.05, 0.05, &data).is_some()); + } +} + +#[test] +fn count_min_sizing_meets_the_target_and_one_step_smaller_does_not() { + let none = FlowData::default(); + for (epsilon, delta) in [(0.1, 0.1), (0.01, 0.01), (0.001, 1e-4)] { + let p = CmHeap::params_for(StatisticKind::ItemFrequency, epsilon, delta, &none).unwrap(); + assert_eq!(p.heap_size, MatrixHeapParams::default().heap_size); + let bound = CmHeap::theoretical_error(StatisticKind::ItemFrequency, &p, &none, Some(delta)); + let bound = bound.unwrap(); + assert!(bound.epsilon <= epsilon && bound.delta.unwrap() <= delta); + let narrower = MatrixHeapParams { + cols: p.cols - 1, + ..p + }; + let wider = CmHeap::theoretical_error(StatisticKind::ItemFrequency, &narrower, &none, None); + assert!( + wider.unwrap().epsilon > epsilon, + "{p:?} is wider than needed" + ); + if p.rows > 1 { + let shallower = MatrixHeapParams { + rows: p.rows - 1, + ..p + }; + let shallow = CmHeap::theoretical_error( + StatisticKind::ItemFrequency, + &shallower, + &none, + Some(delta), + ); + assert!(shallow.is_none(), "{p:?} is deeper than needed"); + } + + let p = CmHeap::params_for(StatisticKind::TopK, epsilon, delta, &TOP_K_FLOWS).unwrap(); + let bound = CmHeap::theoretical_error(StatisticKind::TopK, &p, &TOP_K_FLOWS, Some(delta)); + assert!(bound.unwrap().epsilon <= epsilon); + let shallower = MatrixHeapParams { + rows: p.rows - 1, + ..p + }; + assert!( + CmHeap::theoretical_error(StatisticKind::TopK, &shallower, &TOP_K_FLOWS, Some(delta)) + .is_none() + ); + + let p = CmSs::params_for(StatisticKind::TopK, epsilon, delta, &TOP_K_FLOWS).unwrap(); + let bound = CmSs::theoretical_error(StatisticKind::TopK, &p, &TOP_K_FLOWS, Some(delta)); + assert!(bound.unwrap().epsilon <= epsilon); + + let p = CmFold::params_for( + StatisticKind::ItemFrequency, + epsilon, + delta, + &FoldData::default(), + ) + .unwrap(); + assert!(p.full_cols.is_power_of_two()); + let bound = CmFold::theoretical_error( + StatisticKind::ItemFrequency, + &p, + &FoldData::default(), + Some(delta), + ); + let bound = bound.unwrap(); + assert!(bound.epsilon <= epsilon && bound.delta.unwrap() <= delta); + let narrower = FoldParams { + full_cols: p.full_cols / 2, + ..p + }; + let wider = CmFold::theoretical_error( + StatisticKind::ItemFrequency, + &narrower, + &FoldData::default(), + None, + ); + assert!( + wider.unwrap().epsilon > epsilon, + "{p:?} is wider than needed" + ); + } + // W is at most 65536. + assert!( + CmFold::params_for( + StatisticKind::ItemFrequency, + 1e-5, + 0.01, + &FoldData::default() + ) + .is_none() + ); +} + +/// `F = min(3, log2 W)`, so `new` accepts every sized shape. +#[test] +fn fold_sizing_returns_parameters_new_accepts() { + let none = FoldData::default(); + for epsilon in [0.01, 0.5, 1.0, 3.0, 1e6, 1e200, f64::INFINITY] { + for p in [ + CmFold::params_for(StatisticKind::ItemFrequency, epsilon, 0.01, &none), + CsFold::params_for(StatisticKind::ItemFrequency, epsilon, 0.01, &none), + ] { + let p = p.unwrap_or_else(|| panic!("{epsilon}")); + assert_eq!(p.fold_level, p.full_cols.trailing_zeros().min(3), "{p:?}"); + CmFold::new(p.rows, p.full_cols, p.fold_level, p.heap_size); + CsFold::new(p.rows, p.full_cols, p.fold_level, p.heap_size); + } + } +} + +#[test] +fn count_sketch_sizing_meets_the_target() { + let none = FlowData::default(); + for (epsilon, delta) in [(0.1, 0.1), (0.05, 0.01), (0.02, 1e-3)] { + let p = CsHeap::params_for(StatisticKind::ItemFrequency, epsilon, delta, &none).unwrap(); + let bound = CsHeap::theoretical_error(StatisticKind::ItemFrequency, &p, &none, Some(delta)); + assert!(bound.unwrap().epsilon <= epsilon, "{p:?}"); + let p = CsHeap::params_for(StatisticKind::TopK, epsilon, delta, &TOP_K_FLOWS).unwrap(); + let bound = CsHeap::theoretical_error(StatisticKind::TopK, &p, &TOP_K_FLOWS, Some(delta)); + assert!(bound.unwrap().epsilon <= epsilon, "{p:?}"); + let p = CsSs::params_for(StatisticKind::TopK, epsilon, delta, &TOP_K_FLOWS).unwrap(); + let bound = CsSs::theoretical_error(StatisticKind::TopK, &p, &TOP_K_FLOWS, Some(delta)); + assert!(bound.unwrap().epsilon <= epsilon, "{p:?}"); + for statistic in [StatisticKind::ItemFrequency, StatisticKind::TopK] { + let p = CsFold::params_for(statistic, epsilon, delta, &TOP_K_FOLDS).unwrap(); + assert!(p.full_cols.is_power_of_two() && p.rows % 2 == 1, "{p:?}"); + let bound = CsFold::theoretical_error(statistic, &p, &TOP_K_FOLDS, Some(delta)); + assert!(bound.unwrap().epsilon <= epsilon, "{p:?}"); + } + } + assert!( + CsFold::params_for( + StatisticKind::ItemFrequency, + 1e-3, + 0.01, + &FoldData::default() + ) + .is_none() + ); +} + +#[test] +fn heap_variant_memory_covers_the_state() { + for p in [ + MatrixHeapParams { + rows: 3, + cols: 64, + heap_size: 8, + }, + MatrixHeapParams { + rows: 5, + cols: 1024, + heap_size: 64, + }, + ] { + let mut rng = rng(62, p.heap_size as u64); + let keys = mixed_keys(&mut rng, 40, 64, 3000); + let stream = unit_stream(&mut rng, &keys); + let strings = FlowData { + distinct: None, + key_bytes: Some(16), + merged: None, + }; + let mut cm = CmHeap::build(&p); + let mut cs = CsHeap::build(&p); + let cm_bound = ::theoretical_memory_bytes(&p, &strings).unwrap(); + let cs_bound = ::theoretical_memory_bytes(&p, &strings).unwrap(); + for &id in &stream { + let flow = flow(id); + cm.add(&DataInput::Str(&flow), 1); + cs.add(&DataInput::Str(&flow), 1); + assert!(cm_bound >= cm.memory_bytes()); + assert!(cs_bound >= cs.memory_bytes()); + } + let mut numbers = CmHeap::build(&p); + for &id in &stream { + numbers.add(&key(id), 1); + } + let other = numbers.clone(); + numbers.merge(&other); + let unknown = FlowData::default(); + assert_eq!( + ::theoretical_memory_bytes(&p, &unknown), + None + ); + let numeric = FlowData { + key_bytes: Some(0), + ..unknown + }; + let bound = ::theoretical_memory_bytes(&p, &numeric).unwrap(); + assert!(bound >= numbers.memory_bytes()); + } +} + +#[test] +fn space_saving_variant_memory_covers_the_state() { + for p in [ + MatrixSpaceSavingParams { + rows: 3, + cols: 64, + capacity: 8, + }, + MatrixSpaceSavingParams { + rows: 5, + cols: 1024, + capacity: 128, + }, + ] { + let mut rng = rng(63, p.capacity as u64); + let keys = mixed_keys(&mut rng, 40, 64, 3000); + let strings = FlowData { + distinct: None, + key_bytes: Some(16), + merged: None, + }; + let mut cm = CmSs::build(&p); + let mut cs = CsSs::build(&p); + for id in unit_stream(&mut rng, &keys) { + let flow = flow(id); + cm.add(&DataInput::Str(&flow), 1); + cs.add(&DataInput::Str(&flow), 1); + } + let other = cm.clone(); + cm.merge(&other); + assert_eq!( + ::theoretical_memory_bytes(&p, &FlowData::default()), + None + ); + let bound = ::theoretical_memory_bytes(&p, &strings).unwrap(); + assert!( + bound >= cm.memory_bytes(), + "{bound} < {}", + cm.memory_bytes() + ); + let bound = ::theoretical_memory_bytes(&p, &strings).unwrap(); + assert!( + bound >= cs.memory_bytes(), + "{bound} < {}", + cs.memory_bytes() + ); + } +} + +#[test] +fn fold_memory_covers_the_state() { + for p in [ + FoldParams { + rows: 3, + full_cols: 1024, + fold_level: 3, + heap_size: 8, + }, + FoldParams { + rows: 5, + full_cols: 4096, + fold_level: 5, + heap_size: 32, + }, + ] { + let mut rng = rng(64, p.full_cols as u64); + let keys = mixed_keys(&mut rng, 40, 64, 2000); + let mut cm = CmFold::build(&p); + let mut cs = CsFold::build(&p); + for id in unit_stream(&mut rng, &keys) { + let flow = flow(id); + cm.add(&DataInput::Str(&flow), 1); + cs.add(&DataInput::Str(&flow), 1); + } + let other = cm.clone(); + cm.merge(&other); + let unknown = FoldData { + key_bytes: Some(16), + ..FoldData::default() + }; + assert_eq!( + ::theoretical_memory_bytes(&p, &FoldData::default()), + None + ); + let measured = FoldData { + key_bytes: Some(16), + ..fold_data(cm.cells(), keys.len()) + }; + for data in [measured, unknown] { + let bound = ::theoretical_memory_bytes(&p, &data).unwrap(); + assert!( + bound >= cm.memory_bytes(), + "{data:?}: {bound} < {}", + cm.memory_bytes() + ); + } + let measured = FoldData { + key_bytes: Some(16), + ..fold_data(cs.cells(), keys.len()) + }; + for data in [measured, unknown] { + let bound = ::theoretical_memory_bytes(&p, &data).unwrap(); + assert!( + bound >= cs.memory_bytes(), + "{data:?}: {bound} < {}", + cs.memory_bytes() + ); + } + } +} + +#[cfg(feature = "op-count")] +mod ops { + use super::*; + use asap_sketchlib::TopKHeap; + use asap_sketchlib::analytics::{est_count_min, est_count_sketch, heap_update_ops, ops}; + use rand::Rng; + + #[test] + fn heap_update_costs_at_most_h_k() { + for k in [1, 2, 3, 8, 15, 32, 100] { + let mut heap = TopKHeap::::new(k); + let mut rng = rng(70, k as u64); + let mut worst = 0; + for _ in 0..20_000 { + let id = rng.random_range(0..3 * k as u64 + 5); + let count = rng.random_range(0..1000i64); + let ((), ops) = ops::count(|| { + heap.update(&key(id), count); + }); + worst = worst.max(ops); + } + assert!(worst as f64 <= heap_update_ops(k), "k={k}: {worst}"); + } + } + + /// In a full heap of `2^j − 1` keys every leaf sits `j − 1` levels down, + /// so a new key above every count displaces the root and sinks to a leaf. + #[test] + fn a_full_descent_costs_exactly_h_k() { + for j in 1..=7 { + let k = (1usize << j) - 1; + let mut heap = TopKHeap::::new(k); + for id in 0..k as u64 { + heap.update(&key(id), id as i64 + 1); + } + for id in k as u64..k as u64 + 10 { + let ((), ops) = ops::count(|| { + heap.update(&key(id), 1_000_000 + id as i64); + }); + assert_eq!(ops as f64, heap_update_ops(k), "k={k}"); + } + } + } + + #[test] + fn a_clear_costs_two_per_resident() { + for k in [1, 5, 64] { + let mut heap = TopKHeap::::new(k); + for id in 0..2 * k as u64 { + heap.update(&key(id), id as i64 + 1); + } + let ((), ops) = ops::count(|| heap.clear()); + assert_eq!(ops, 2 * k as u64, "k={k}"); + } + } + + const HEAP_SIZES: [MatrixHeapParams; 2] = [ + MatrixHeapParams { + rows: 3, + cols: 64, + heap_size: 8, + }, + MatrixHeapParams { + rows: 5, + cols: 1024, + heap_size: 32, + }, + ]; + + /// Two loaded states and the ids streamed into the first. + fn loaded(salt: u64, p: &T::Params) -> (T, T, Vec) { + let mut rng = rng(salt, 0); + let keys = mixed_keys(&mut rng, 40, 64, 1000); + let stream = unit_stream(&mut rng, &keys); + let (mut a, mut b) = (T::build(p), T::build(p)); + for &id in &stream { + a.add(&key(id), 1); + b.add(&key(id ^ 1), 1); + } + (a, b, stream) + } + + fn heap_variant>( + salt: u64, + est: fn(usize) -> f64, + ) { + for p in HEAP_SIZES { + let none = FlowData::default(); + assert_eq!( + T::theoretical_update_ops(&p, &none), + p.rows as f64 + est(p.rows) + heap_update_ops(p.heap_size) + ); + let (mut a, b, stream) = loaded::(salt, &p); + let update = T::theoretical_update_ops(&p, &none); + for &id in stream.iter().take(500) { + assert_ops_within("update", update, || a.add(&key(id), 1)); + } + for id in 0..50 { + assert_ops_within("new key", update, || a.add(&key(u64::MAX - id), 1_000)); + } + let query = T::theoretical_query_ops(StatisticKind::ItemFrequency, &p, &none).unwrap(); + assert_ops_within("query", query, || a.estimate(stream[0])); + let top = T::theoretical_query_ops(StatisticKind::TopK, &p, &none).unwrap(); + assert_ops_within("top_k", top, || a.reported()); + let merge = T::theoretical_merge_ops(&p, &none).unwrap(); + assert_ops_within("merge", merge, || a.merge(&b)); + } + } + + #[test] + fn count_min_with_heap() { + heap_variant::(71, est_count_min); + } + + #[test] + fn count_sketch_with_heap() { + heap_variant::(72, est_count_sketch); + } + + fn space_saving_variant>(salt: u64) { + for p in [ + MatrixSpaceSavingParams { + rows: 3, + cols: 64, + capacity: 16, + }, + MatrixSpaceSavingParams { + rows: 5, + cols: 1024, + capacity: 64, + }, + ] { + let none = FlowData::default(); + let (mut a, b, stream) = loaded::(salt, &p); + let update = T::theoretical_update_ops(&p, &none); + for &id in stream.iter().take(500) { + assert_ops_within("update", update, || a.add(&key(id), 1)); + } + for id in 0..50 { + assert_ops_within("new key", update, || a.add(&key(u64::MAX - id), 1)); + } + let query = T::theoretical_query_ops(StatisticKind::ItemFrequency, &p, &none).unwrap(); + assert_ops_within("query", query, || a.estimate(stream[0])); + let top = T::theoretical_query_ops(StatisticKind::TopK, &p, &none).unwrap(); + assert_ops_within("top_k", top, || a.reported()); + let few = T::build(&p); + let data = FlowData { + distinct: Some(0), + key_bytes: None, + merged: None, + }; + let top = T::theoretical_query_ops(StatisticKind::TopK, &p, &data).unwrap(); + assert_ops_within("empty top_k", top, || few.reported()); + let merge = T::theoretical_merge_ops(&p, &none).unwrap(); + assert_ops_within("merge", merge, || a.merge(&b)); + } + } + + #[test] + fn count_min_with_space_saving() { + space_saving_variant::(73); + } + + #[test] + fn count_sketch_with_space_saving() { + space_saving_variant::(74); + } + + trait Fold: TopK { + fn cells(&self) -> &[asap_sketchlib::FoldCell]; + fn hierarchical(states: &[Self]) -> Self; + } + + impl Fold for CmFold { + fn cells(&self) -> &[asap_sketchlib::FoldCell] { + CmFold::cells(self) + } + fn hierarchical(states: &[Self]) -> Self { + CmFold::hierarchical_merge(states) + } + } + + impl Fold for CsFold { + fn cells(&self) -> &[asap_sketchlib::FoldCell] { + CsFold::cells(self) + } + fn hierarchical(states: &[Self]) -> Self { + CsFold::hierarchical_merge(states) + } + } + + fn fold_variant(salt: u64, est: fn(usize) -> f64) { + for p in [ + FoldParams { + rows: 3, + full_cols: 1024, + fold_level: 3, + heap_size: 8, + }, + FoldParams { + rows: 5, + full_cols: 4096, + fold_level: 6, + heap_size: 32, + }, + ] { + let (mut a, b, stream) = loaded::(salt, &p); + let distinct = stream + .iter() + .collect::>() + .len() + + 50; + let mut finished = a.clone(); + for &id in stream.iter().take(500) { + finished.add(&key(id), 1); + } + for id in 0..50 { + finished.add(&key(u64::MAX - id), 1); + } + let data = fold_data(finished.cells(), distinct); + let update = T::theoretical_update_ops(&p, &data); + for &id in stream.iter().take(500) { + assert_ops_within("update", update, || a.add(&key(id), 1)); + } + for id in 0..50 { + assert_ops_within("new key", update, || a.add(&key(u64::MAX - id), 1)); + } + let unknown = T::theoretical_update_ops(&p, &FoldData::default()); + assert!(unknown >= update); + let query = T::theoretical_query_ops(StatisticKind::ItemFrequency, &p, &data).unwrap(); + for &id in stream.iter().take(50) { + assert_ops_within("query", query, || a.estimate(id)); + } + let top = T::theoretical_query_ops(StatisticKind::TopK, &p, &data).unwrap(); + assert_ops_within("top_k", top, || a.reported()); + + let mut merged = a.clone(); + merged.merge(&b); + let both = distinct * 2; + let data = fold_data(merged.cells(), both); + let merge = T::theoretical_merge_ops(&p, &data).unwrap(); + assert_ops_within("merge", merge, || a.merge(&b)); + let unknown = T::theoretical_merge_ops(&p, &FoldData::default()).unwrap(); + assert!(unknown >= merge); + + // hierarchical_merge: dW + Σ (dW/2^F_i + 2e_i + k_i·(1 + est(d) + h(k))). + let states = [a.clone(), b.clone(), T::build(&p)]; + let d = p.rows as f64; + let w = p.full_cols as f64; + let mut bound = d * w; + for s in &states { + let entries: usize = s.cells().iter().map(|c| c.entry_count()).sum(); + let keys = s.reported().len() as f64; + bound += s.cells().len() as f64 + 2.0 * entries as f64; + bound += keys * (1.0 + est(p.rows) + heap_update_ops(p.heap_size)); + } + assert_ops_within("hierarchical merge", bound, || T::hierarchical(&states)); + } + } + + #[test] + fn count_min_fold() { + fold_variant::(75, est_count_min); + } + + #[test] + fn count_sketch_fold() { + fold_variant::(76, est_count_sketch); + } +} diff --git a/tests/analytics/univmon.rs b/tests/analytics/univmon.rs new file mode 100644 index 00000000..dab98997 --- /dev/null +++ b/tests/analytics/univmon.rs @@ -0,0 +1,897 @@ +//! UnivMon, UnivMonTwoTier and UnivMonQ. + +use crate::support::*; +use asap_sketchlib::analytics::{ + FlowData, TwoTierParams, UnivMonParams, UnivMonQData, max_row_failure, p_med, +}; +use asap_sketchlib::{ + DataInput, ErrorBound, ErrorMetric, SketchAnalytics, StatisticKind, UnivMon, UnivMonQ, + UnivMonQConfig, UnivMonTwoTier, +}; +use rand::Rng; +use rand::rngs::StdRng; + +fn key(id: u64) -> DataInput<'static> { + DataInput::U64(id) +} + +fn distinct(d: u64) -> FlowData { + FlowData { + distinct: Some(d), + key_bytes: None, + merged: None, + } +} + +/// One pyramid of the family, driven the same way by every test. +trait Pyramid: SketchAnalytics + Clone { + fn build(params: &Self::Params) -> Self; + fn add(&mut self, id: u64, weight: i64); + fn l1(&self) -> f64; + fn l2(&self) -> f64; + fn cardinality(&self) -> f64; + #[cfg_attr(not(feature = "op-count"), allow(dead_code))] + fn entropy(&self) -> f64; + #[cfg_attr(not(feature = "op-count"), allow(dead_code))] + fn f2_sum(&self) -> f64; + fn top_complete(&self) -> bool; + fn merge(&mut self, other: &Self); +} + +impl Pyramid for UnivMon { + fn build(p: &UnivMonParams) -> Self { + UnivMon::new(p.heap_size, p.rows, p.cols, p.layers) + } + fn add(&mut self, id: u64, weight: i64) { + self.update_weighted(&key(id), weight); + } + fn l1(&self) -> f64 { + self.estimate_l1() + } + fn l2(&self) -> f64 { + self.estimate_l2() + } + fn cardinality(&self) -> f64 { + self.estimate_cardinality() + } + fn entropy(&self) -> f64 { + self.estimate_entropy() + } + fn f2_sum(&self) -> f64 { + self.estimate_g_sum(|x| x * x, false) + } + fn top_complete(&self) -> bool { + self.top_layer_complete() + } + fn merge(&mut self, other: &Self) { + UnivMon::merge(self, other); + } +} + +impl Pyramid for UnivMonTwoTier { + fn build(p: &TwoTierParams) -> Self { + UnivMonTwoTier::new( + p.heap_size, + p.elephant_layers, + p.elephant_rows, + p.elephant_cols, + p.mouse_rows, + p.mouse_cols, + p.layers, + ) + } + fn add(&mut self, id: u64, weight: i64) { + self.update_weighted(&key(id), weight); + } + fn l1(&self) -> f64 { + self.estimate_l1() + } + fn l2(&self) -> f64 { + self.estimate_l2() + } + fn cardinality(&self) -> f64 { + self.estimate_cardinality() + } + fn entropy(&self) -> f64 { + self.estimate_entropy() + } + fn f2_sum(&self) -> f64 { + self.estimate_g_sum(|x| x * x, false) + } + fn top_complete(&self) -> bool { + self.top_layer_complete() + } + fn merge(&mut self, other: &Self) { + UnivMonTwoTier::merge(self, other); + } +} + +const UNIVMON_SIZES: [UnivMonParams; 2] = [ + UnivMonParams { + heap_size: 8, + rows: 3, + cols: 64, + layers: 4, + }, + UnivMonParams { + heap_size: 32, + rows: 5, + cols: 512, + layers: 7, + }, +]; + +const TWO_TIER_SIZES: [TwoTierParams; 2] = [ + TwoTierParams { + heap_size: 8, + elephant_layers: 2, + elephant_rows: 3, + elephant_cols: 64, + mouse_rows: 1, + mouse_cols: 16, + layers: 4, + }, + TwoTierParams { + heap_size: 32, + elephant_layers: 3, + elephant_rows: 5, + elephant_cols: 512, + mouse_rows: 3, + mouse_cols: 128, + layers: 7, + }, +]; + +/// Distinct over trials: a trial fails when the top layer overflowed or the +/// estimate is off by more than `ε·D`, which together have probability at +/// most `δ`. +fn distinct_trials(salt: u64, params: &P::Params, keys: usize, delta: f64) { + let data = distinct(keys as u64); + let bound = P::theoretical_error(StatisticKind::Distinct, params, &data, Some(delta)).unwrap(); + assert_eq!(bound.metric, ErrorMetric::Relative); + assert!(bound.epsilon < 0.15, "{bound:?}"); + let trials = 150; + let mut failures = 0; + for trial in 0..trials { + let mut rng = rng(salt, trial); + let stream = mixed_keys(&mut rng, 20, 64, keys - 20); + let mut sketch = P::build(params); + for &(id, w) in &stream { + sketch.add(id, w as i64); + } + let truth = keys as f64; + if !sketch.top_complete() || (sketch.cardinality() - truth).abs() > bound.epsilon * truth { + failures += 1; + } + } + assert!( + failures <= allowed_failures(trials as usize, delta), + "{failures}/{trials} beyond eps={} at delta={delta}", + bound.epsilon + ); +} + +/// FrequencyL2 over trials, at its fixed `δ`. +fn l2_trials(salt: u64, params: &P::Params) { + let bound = P::theoretical_error( + StatisticKind::FrequencyL2, + params, + &FlowData::default(), + None, + ) + .unwrap(); + assert_eq!(bound.metric, ErrorMetric::Relative); + let delta = bound.delta.unwrap(); + let trials = 300; + let mut failures = 0; + for trial in 0..trials { + let mut rng = rng(salt, trial); + let stream = mixed_keys(&mut rng, 50, 64, 1000); + let mut sketch = P::build(params); + for &(id, w) in &stream { + sketch.add(id, w as i64); + } + let truth = l2(&stream); + if (sketch.l2() - truth).abs() > bound.epsilon * truth { + failures += 1; + } + } + assert!( + failures <= allowed_failures(trials as usize, delta), + "{failures}/{trials} beyond eps={} at delta={delta}", + bound.epsilon + ); +} + +#[test] +fn univmon_distinct_is_within_relative_epsilon_with_probability_one_minus_delta() { + let params = UnivMonParams { + heap_size: 256, + rows: 3, + cols: 256, + layers: 7, + }; + distinct_trials::(1, ¶ms, 4000, 0.3); +} + +#[test] +fn univmon_l2_is_within_relative_epsilon_with_probability_one_minus_delta() { + let params = UnivMonParams { + heap_size: 8, + rows: 5, + cols: 1024, + layers: 2, + }; + let bound = UnivMon::theoretical_error( + StatisticKind::FrequencyL2, + ¶ms, + &FlowData::default(), + None, + ) + .unwrap(); + assert_eq!(bound.delta, Some(p_med(5, 0.1))); + l2_trials::(2, ¶ms); +} + +#[test] +fn two_tier_distinct_is_within_relative_epsilon_with_probability_one_minus_delta() { + let params = TwoTierParams { + heap_size: 256, + elephant_layers: 2, + elephant_rows: 3, + elephant_cols: 256, + mouse_rows: 3, + mouse_cols: 64, + layers: 7, + }; + distinct_trials::(3, ¶ms, 4000, 0.3); +} + +#[test] +fn two_tier_l2_reads_layer_zero_dimensions() { + let params = TwoTierParams { + heap_size: 8, + elephant_layers: 1, + elephant_rows: 5, + elephant_cols: 1024, + mouse_rows: 1, + mouse_cols: 16, + layers: 2, + }; + let bound = UnivMonTwoTier::theoretical_error( + StatisticKind::FrequencyL2, + ¶ms, + &FlowData::default(), + None, + ) + .unwrap(); + assert_eq!(bound.delta, Some(p_med(5, 0.1))); + l2_trials::(4, ¶ms); +} + +#[test] +fn univmon_total_is_exact_and_distinct_needs_d_and_room_above_psi() { + let p = UnivMonParams::default(); + let total = + UnivMon::theoretical_error(StatisticKind::Total, &p, &FlowData::default(), None).unwrap(); + assert_eq!(total.epsilon, 0.0); + assert_eq!(total.contract, "univmon_unit_update_total"); + let mut sketch = UnivMon::build(&p); + for id in 0..100 { + sketch.add(id, id as i64 + 1); + } + assert_eq!(sketch.l1(), 5050.0); + for statistic in [StatisticKind::FrequencyEntropy, StatisticKind::GSum] { + assert_eq!( + UnivMon::theoretical_error(statistic, &p, &distinct(10), None), + None + ); + } + assert_eq!( + UnivMon::theoretical_error(StatisticKind::Distinct, &p, &FlowData::default(), None), + None + ); + // k = 32 and D = 10^4 need L ≥ lg(312.5) + 2 = 11, and 8 layers leave ψ = 1. + assert_eq!( + UnivMon::theoretical_error(StatisticKind::Distinct, &p, &distinct(10_000), Some(0.5)), + None + ); + let deep = UnivMonParams { layers: 11, ..p }; + let psi = (std::f64::consts::E / 4.0).powf(16.0); + assert_eq!( + UnivMon::theoretical_error(StatisticKind::Distinct, &deep, &distinct(10_000), Some(psi)), + None + ); + let bound = + UnivMon::theoretical_error(StatisticKind::Distinct, &deep, &distinct(10_000), Some(0.1)) + .unwrap(); + assert!((bound.epsilon - (1.35 / (32.0 * (0.1 - psi))).sqrt()).abs() < 1e-12); + let small_heap = UnivMonParams { heap_size: 7, ..p }; + assert_eq!( + UnivMon::theoretical_error(StatisticKind::Distinct, &small_heap, &distinct(5), None), + None + ); +} + +#[test] +fn univmon_distinct_sizing_meets_the_target_and_one_step_smaller_does_not() { + let data = distinct(100_000); + for (epsilon, delta) in [(0.5, 0.3), (0.2, 0.1), (0.1, 0.05)] { + for two_tier in [false, true] { + let (k, layers, bound, smaller_heap, fewer_layers) = if two_tier { + let p = UnivMonTwoTier::params_for(StatisticKind::Distinct, epsilon, delta, &data) + .unwrap(); + let err = |q: &TwoTierParams| { + UnivMonTwoTier::theoretical_error( + StatisticKind::Distinct, + q, + &data, + Some(delta), + ) + }; + ( + p.heap_size, + p.layers, + err(&p), + err(&TwoTierParams { + heap_size: p.heap_size - 1, + ..p + }), + err(&TwoTierParams { + layers: p.layers - 1, + ..p + }), + ) + } else { + let p = + UnivMon::params_for(StatisticKind::Distinct, epsilon, delta, &data).unwrap(); + let err = |q: &UnivMonParams| { + UnivMon::theoretical_error(StatisticKind::Distinct, q, &data, Some(delta)) + }; + ( + p.heap_size, + p.layers, + err(&p), + err(&UnivMonParams { + heap_size: p.heap_size - 1, + ..p + }), + err(&UnivMonParams { + layers: p.layers - 1, + ..p + }), + ) + }; + let bound = bound.unwrap(); + assert!(bound.epsilon <= epsilon, "k={k}, L={layers}: {bound:?}"); + assert!( + smaller_heap.is_none_or(|b| b.epsilon > epsilon), + "k={k} is larger than needed" + ); + assert!(fewer_layers.is_none(), "L={layers} is deeper than needed"); + } + } + assert_eq!( + UnivMon::params_for(StatisticKind::Distinct, 0.1, 0.05, &FlowData::default()), + None + ); + // 2^25 keys at k = 2700 need L = lg(12428) + 2 = 16; 2^40 keys need 31. + assert!(UnivMon::params_for(StatisticKind::Distinct, 0.1, 0.05, &distinct(1 << 25)).is_some()); + assert_eq!( + UnivMon::params_for(StatisticKind::Distinct, 0.1, 0.05, &distinct(1 << 40)), + None + ); +} + +#[test] +fn univmon_l2_sizing_meets_the_target() { + for (epsilon, delta) in [(0.2, 0.05), (0.1, 0.01)] { + let p = UnivMon::params_for( + StatisticKind::FrequencyL2, + epsilon, + delta, + &FlowData::default(), + ) + .unwrap(); + let bound = UnivMon::theoretical_error( + StatisticKind::FrequencyL2, + &p, + &FlowData::default(), + Some(delta), + ) + .unwrap(); + assert!(bound.epsilon <= epsilon && bound.delta.unwrap() <= delta); + let narrower = UnivMonParams { + cols: p.cols / 2, + ..p + }; + assert!( + UnivMon::theoretical_error( + StatisticKind::FrequencyL2, + &narrower, + &FlowData::default(), + None + ) + .is_none_or(|b| b.epsilon > epsilon) + ); + } +} + +fn loaded(params: &P::Params, salt: u64, keys: usize) -> P { + let mut rng = rng(salt, 0); + let mut sketch = P::build(params); + for (id, w) in mixed_keys(&mut rng, 30, 64, keys) { + sketch.add(id, w as i64); + } + sketch +} + +#[test] +fn pyramid_memory_covers_the_state() { + let numeric = FlowData { + key_bytes: Some(0), + ..FlowData::default() + }; + for p in UNIVMON_SIZES { + let mut sketch: UnivMon = loaded(&p, 5, 3000); + assert_eq!( + ::theoretical_memory_bytes(&p, &FlowData::default()), + None + ); + let bound = ::theoretical_memory_bytes(&p, &numeric).unwrap(); + assert!(bound >= sketch.memory_bytes(), "{p:?}"); + Pyramid::merge(&mut sketch, &loaded(&p, 15, 3000)); + assert!(bound >= sketch.memory_bytes(), "{p:?} merged"); + } + for p in TWO_TIER_SIZES { + let mut sketch: UnivMonTwoTier = loaded(&p, 6, 3000); + assert_eq!( + ::theoretical_memory_bytes(&p, &FlowData::default()), + None + ); + let bound = + ::theoretical_memory_bytes(&p, &numeric).unwrap(); + assert!(bound >= sketch.memory_bytes(), "{p:?}"); + Pyramid::merge(&mut sketch, &loaded(&p, 16, 3000)); + assert!(bound >= sketch.memory_bytes(), "{p:?} merged"); + } +} + +#[cfg(feature = "op-count")] +mod pyramid_ops { + use super::*; + use asap_sketchlib::analytics::ops; + + fn pyramid_ops(sizes: &[P::Params]) { + for (i, p) in sizes.iter().enumerate() { + let mut a: P = loaded(p, 7 + i as u64, 2000); + let b: P = loaded(p, 17 + i as u64, 2000); + let data = FlowData::default(); + let mut rng = rng(27, i as u64); + let updates = 2000; + let ((), counted) = ops::count(|| { + for _ in 0..updates { + a.add(rng.random(), 1); + } + }); + let average = counted as f64 / updates as f64; + let bound = P::theoretical_update_ops(p, &data); + assert!( + average <= bound, + "update averages {average}, formula {bound}" + ); + type Read

= fn(&P) -> f64; + for (statistic, read) in [ + (StatisticKind::Total, P::l1 as Read

), + (StatisticKind::FrequencyL2, P::l2), + (StatisticKind::Distinct, P::cardinality), + (StatisticKind::FrequencyEntropy, P::entropy), + (StatisticKind::GSum, P::f2_sum), + ] { + let bound = P::theoretical_query_ops(statistic, p, &data).unwrap(); + assert_ops_within(&format!("{statistic:?}"), bound, || read(&a)); + } + let merge = P::theoretical_merge_ops(p, &data).unwrap(); + assert_ops_within("merge", merge, || a.merge(&b)); + } + } + + #[test] + fn univmon() { + pyramid_ops::(&UNIVMON_SIZES); + } + + #[test] + fn univmon_two_tier() { + pyramid_ops::(&TWO_TIER_SIZES); + } +} + +fn univmonq(config: UnivMonQConfig, rng: &mut StdRng) -> UnivMonQ { + UnivMonQ::with_source_id(config, rng.random()).unwrap() +} + +const ITEM_CONFIG: UnivMonQConfig = UnivMonQConfig { + layers: 2, + top_layer_cols: 128, + cols_halving_period: 0, + rows: 3, + counter_bits: 32, + heap_size: 16, + ordered_samples: 0, + seed_index: 5, +}; + +/// The two heaviest keys each carry more than `ε·‖f‖₂`, and 12000 unit keys +/// put about 47 updates in every column of each layer. +#[test] +fn univmonq_item_frequency_is_off_by_at_most_epsilon_l2_with_probability_one_minus_delta() { + let delta = 0.2; + let bound = UnivMonQ::::theoretical_error( + StatisticKind::ItemFrequency, + &ITEM_CONFIG, + &UnivMonQData::default(), + Some(delta), + ) + .unwrap(); + assert_eq!(bound.metric, ErrorMetric::L2); + assert!((bound.epsilon - (1.0 / 128.0 / max_row_failure(3, delta)).sqrt()).abs() < 1e-12); + let trials = 150; + let mut failures = 0; + for trial in 0..trials { + let mut rng = rng(8, trial); + let values = mixed_keys(&mut rng, 30, 100, 12_000); + let mut sketch = univmonq(ITEM_CONFIG, &mut rng); + for &(id, count) in &values { + let value = id as f64; + for _ in 0..count { + sketch.update(&value); + } + } + let (target, truth) = values[(trial % 2) as usize]; + assert!(truth as f64 > 1.5 * bound.epsilon * l2(&values)); + let estimate = sketch.estimate_frequency(target as f64) as f64; + if (estimate - truth as f64).abs() > bound.epsilon * l2(&values) { + failures += 1; + } + } + assert!( + failures <= allowed_failures(trials as usize, delta), + "{failures}/{trials} beyond eps={} at delta={delta}", + bound.epsilon + ); +} + +const SAMPLE_CONFIG: UnivMonQConfig = UnivMonQConfig { + layers: 4, + top_layer_cols: 64, + cols_halving_period: 0, + rows: 3, + counter_bits: 32, + heap_size: 16, + ordered_samples: 256, + seed_index: 5, +}; + +/// Distinct values in [0, 1) that arrive in increasing order, so no value is +/// frequent, `F̂₂ ≤ 2n`, the heavy set is empty, and a sample biased toward +/// any part of the stream misses the quantiles. +fn diffuse(rng: &mut StdRng, n: usize) -> (UnivMonQ, Vec) { + let mut sketch = univmonq(SAMPLE_CONFIG, rng); + let values: Vec = (0..n) + .map(|i| (i as f64 + rng.random::()) / n as f64) + .collect(); + for value in &values { + sketch.update(value); + } + assert!(sketch.estimate_f2() <= 2.0 * n as f64); + assert!( + sketch + .prepare_queries() + .ordered_query_diagnostics() + .heavy + .is_empty(), + "a diffuse stream left a heavy set" + ); + (sketch, values) +} + +fn sample_bound(statistic: StatisticKind, n: usize, delta: f64) -> ErrorBound { + let data = UnivMonQData { + n: Some(n as u64), + f2_estimate: Some(2.0 * n as f64), + }; + UnivMonQ::::theoretical_error( + statistic, + &SAMPLE_CONFIG, + &data, + Some(delta), + ) + .unwrap() +} + +#[test] +fn univmonq_quantile_rank_is_within_epsilon_n_with_probability_one_minus_delta() { + let (n, delta) = (3000, 0.05); + let bound = sample_bound(StatisticKind::Quantile, n, delta); + assert_eq!(bound.metric, ErrorMetric::Rank); + let trials = 200; + let mut failures = 0; + for trial in 0..trials { + let mut rng = rng(9, trial); + let (sketch, values) = diffuse(&mut rng, n); + let q = (trial % 9 + 1) as f64 / 10.0; + let estimate = sketch.quantile(q).unwrap(); + let rank = values.iter().filter(|&&v| v <= estimate).count() as f64; + if (rank - q * n as f64).abs() > bound.epsilon * n as f64 { + failures += 1; + } + } + assert!( + failures <= allowed_failures(trials as usize, delta), + "{failures}/{trials} beyond eps={} at delta={delta}", + bound.epsilon + ); +} + +#[test] +fn univmonq_rank_is_within_epsilon_n_with_probability_one_minus_delta() { + let (n, delta) = (3000, 0.05); + let bound = sample_bound(StatisticKind::Rank, n, delta); + assert_eq!(bound.metric, ErrorMetric::L1); + let trials = 200; + let mut failures = 0; + for trial in 0..trials { + let mut rng = rng(10, trial); + let (sketch, values) = diffuse(&mut rng, n); + let x: f64 = rng.random(); + let truth = values.iter().filter(|&&v| v <= x).count() as f64; + let estimate = sketch.rank(x).unwrap() as f64; + if (estimate - truth).abs() > bound.epsilon * n as f64 { + failures += 1; + } + } + assert!( + failures <= allowed_failures(trials as usize, delta), + "{failures}/{trials} beyond eps={} at delta={delta}", + bound.epsilon + ); +} + +#[test] +fn univmonq_bounds_need_their_conditions() { + type Q = UnivMonQ; + let c = SAMPLE_CONFIG; + let full = UnivMonQData { + n: Some(10_000), + f2_estimate: Some(10_000.0), + }; + // `F̂₂·M < n²` holds at M = 256 and fails at M = 512. + let concentrated = UnivMonQData { + f2_estimate: Some(300_000.0), + ..full + }; + let more_samples = UnivMonQConfig { + ordered_samples: 512, + ..c + }; + for statistic in [StatisticKind::Quantile, StatisticKind::Rank] { + // Rounding to a count, or to the rank ⌈qn⌉, adds 1/n. + let bound = Q::theoretical_error(statistic, &c, &full, None).unwrap(); + let sampled = ((2.0f64 / 0.01).ln() / 512.0).sqrt(); + assert!((bound.epsilon - sampled - 1e-4).abs() < 1e-12, "{bound:?}"); + assert!(Q::theoretical_error(statistic, &c, &concentrated, None).is_some()); + assert_eq!( + Q::theoretical_error(statistic, &more_samples, &concentrated, None), + None + ); + for data in [ + UnivMonQData::default(), + UnivMonQData { + f2_estimate: Some(1e8 / 256.0), + ..full + }, + UnivMonQData { + f2_estimate: None, + ..full + }, + UnivMonQData { n: None, ..full }, + ] { + assert_eq!(Q::theoretical_error(statistic, &c, &data, None), None); + } + let no_samples = UnivMonQConfig { + ordered_samples: 0, + ..c + }; + assert_eq!( + Q::theoretical_error(statistic, &no_samples, &full, None), + None + ); + } + // min(M, n): fewer updates than samples widen the bound. + let few = UnivMonQData { + n: Some(64), + f2_estimate: Some(10.0), + }; + assert!( + Q::theoretical_error(StatisticKind::Rank, &c, &few, None) + .unwrap() + .epsilon + > Q::theoretical_error(StatisticKind::Rank, &c, &full, None) + .unwrap() + .epsilon + ); + for statistic in [ + StatisticKind::Total, + StatisticKind::FrequencyL2, + StatisticKind::Distinct, + StatisticKind::GSum, + StatisticKind::FrequencyEntropy, + StatisticKind::TopK, + ] { + assert_eq!(Q::theoretical_error(statistic, &c, &full, None), None); + } +} + +#[test] +fn univmonq_sizing_meets_the_target_and_one_step_smaller_does_not() { + type Q = UnivMonQ; + let none = UnivMonQData::default(); + for (epsilon, delta) in [(0.1, 0.1), (0.05, 0.01), (0.01, 1e-4)] { + let c = Q::params_for(StatisticKind::ItemFrequency, epsilon, delta, &none).unwrap(); + let bound = + Q::theoretical_error(StatisticKind::ItemFrequency, &c, &none, Some(delta)).unwrap(); + assert!(bound.epsilon <= epsilon, "{c:?} gives {bound:?}"); + let narrower = UnivMonQConfig { + top_layer_cols: c.top_layer_cols / 2, + ..c + }; + let wider = + Q::theoretical_error(StatisticKind::ItemFrequency, &narrower, &none, Some(delta)) + .unwrap(); + assert!(wider.epsilon > epsilon, "{c:?} is wider than needed"); + + for statistic in [StatisticKind::Quantile, StatisticKind::Rank] { + let data = UnivMonQData { + n: Some(1 << 40), + f2_estimate: Some((1u64 << 40) as f64), + }; + assert_eq!(Q::params_for(statistic, epsilon, delta, &none), None); + let c = Q::params_for(statistic, epsilon, delta, &data).unwrap(); + let bound = Q::theoretical_error(statistic, &c, &data, Some(delta)).unwrap(); + assert!(bound.epsilon <= epsilon); + let fewer = UnivMonQConfig { + ordered_samples: c.ordered_samples - 1, + ..c + }; + assert!( + Q::theoretical_error(statistic, &fewer, &data, Some(delta)) + .unwrap() + .epsilon + > epsilon + ); + } + } + // M = ⌈ln 20/(2·0.0999²)⌉ = 151 at (0.1, 0.1) and n = 10^4: + // `F̂₂·M < n² = 10^8` needs `F̂₂ < 662 252`. + let sized = |f2: f64| { + let data = UnivMonQData { + n: Some(10_000), + f2_estimate: Some(f2), + }; + let c = Q::params_for(StatisticKind::Quantile, 0.1, 0.1, &data)?; + assert!(Q::theoretical_error(StatisticKind::Quantile, &c, &data, Some(0.1)).is_some()); + Some(c.ordered_samples) + }; + assert_eq!(sized(600_000.0), Some(151)); + assert_eq!(sized(662_252.0), None); +} + +const Q_SIZES: [UnivMonQConfig; 2] = [ + UnivMonQConfig { + layers: 3, + top_layer_cols: 32, + cols_halving_period: 1, + rows: 3, + counter_bits: 32, + heap_size: 8, + ordered_samples: 16, + seed_index: 5, + }, + UnivMonQConfig { + layers: 6, + top_layer_cols: 256, + cols_halving_period: 2, + rows: 5, + counter_bits: 64, + heap_size: 64, + ordered_samples: 300, + seed_index: 5, + }, +]; + +fn loaded_q(config: UnivMonQConfig, salt: u64) -> UnivMonQ { + let mut rng = rng(salt, 0); + let mut sketch = univmonq(config, &mut rng); + for (id, count) in mixed_keys(&mut rng, 30, 64, 3000) { + for _ in 0..count { + sketch.update(&((id % 100_000) as f64)); + } + } + sketch +} + +#[test] +fn univmonq_memory_covers_the_state() { + for c in Q_SIZES { + let bound = + ::theoretical_memory_bytes(&c, &UnivMonQData::default()) + .unwrap(); + let fresh = UnivMonQ::new(c).unwrap(); + assert!(bound >= fresh.memory_bytes()); + let sketch = loaded_q(c, 11); + assert!(bound >= sketch.memory_bytes(), "{c:?}"); + } +} + +#[test] +fn univmonq_memory_covers_cloned_and_decoded_partial_states() { + for c in Q_SIZES { + let bound = UnivMonQ::::theoretical_memory_bytes(c); + let mut rng = rng(13, c.heap_size as u64); + let mut partial = univmonq(c, &mut rng); + for i in 0..c.ordered_samples * 3 / 4 { + partial.update(&(i as f64)); + } + let decoded = UnivMonQ::from_asapv1(&partial.to_asapv1().unwrap()).unwrap(); + for mut state in [partial.clone(), decoded] { + for i in 0..20_000 { + state.update(&(i as f64)); + } + assert!( + bound >= state.memory_bytes(), + "{c:?}: {bound} < {}", + state.memory_bytes() + ); + } + } +} + +#[cfg(feature = "op-count")] +#[test] +fn univmonq_ops() { + type Q = UnivMonQ; + let data = UnivMonQData::default(); + for (i, c) in Q_SIZES.into_iter().enumerate() { + let mut a = loaded_q(c, 12 + i as u64); + let b = loaded_q(c, 22 + i as u64); + let update = Q::theoretical_update_ops(&c, &data); + let mut rng = rng(32, i as u64); + for _ in 0..200 { + let value = rng.random::(); + assert_ops_within("update", update, || a.update(&value)); + } + // Later passes rescore residents, so a new key re-queues stale entries. + let mut fresh = univmonq(c, &mut rng); + let mut worst = 0; + for pass in 1..=4 { + for j in 0..pass * c.heap_size { + let value = j as f64; + let ((), ops) = asap_sketchlib::analytics::ops::count(|| fresh.update(&value)); + worst = worst.max(ops); + } + } + assert!( + worst as f64 <= update, + "update counted {worst}, formula {update}" + ); + let query = Q::theoretical_query_ops(StatisticKind::ItemFrequency, &c, &data).unwrap(); + assert_ops_within("ItemFrequency", query, || a.estimate_frequency(7.0)); + assert_eq!( + Q::theoretical_query_ops(StatisticKind::Quantile, &c, &data), + None + ); + let merge = Q::theoretical_merge_ops(&c, &data).unwrap(); + assert_ops_within("merge", merge, || a.merge(&b).unwrap()); + } +} diff --git a/tests/capability.rs b/tests/capability.rs index 942ba790..f6abffaa 100644 --- a/tests/capability.rs +++ b/tests/capability.rs @@ -23,7 +23,7 @@ const QUANTILE_RANK: Readouts = &[(Quantile, U), (Rank, U)]; #[rustfmt::skip] const DECIDED: &[(SketchAlgorithm, Option, Readouts)] = &[ - (F::CountMinSketch, Some(D), &[(ItemFrequency, U), (Total, E)]), + (F::CountMinSketch, Some(D), &[(ItemFrequency, U), (Total, U)]), (F::CountSketch, Some(D), &[(ItemFrequency, U)]), (F::CountSketchWithL2Norm, Some(D), &[(ItemFrequency, U), (FrequencyL2, U)]), (F::CountMinSketchWithHeap, Some(D), FREQ_TOP_K), @@ -37,7 +37,7 @@ const DECIDED: &[(SketchAlgorithm, Option, Readouts)] = &[ (F::KMV, Some(O), &[(Distinct, U)]), (F::KLL, Some(D), QUANTILE_RANK), (F::KLLDynamic, Some(D), QUANTILE_RANK), - (F::DDSketch, Some(D), &[(Quantile, U)]), + (F::DDSketch, Some(D), &[(Quantile, U), (QuantileInterpolated, U)]), (F::SpaceSaving, Some(D), &[(ItemFrequency, A), (TopK, A)]), (F::ElasticSketch, Some(D), &[(ItemFrequency, A), (TopK, A)]), (F::CocoSketch, Some(D), &[(ItemFrequency, A), (PartialKeySum, A)]), diff --git a/tests/e2e/membership.rs b/tests/e2e/membership.rs index d64d0856..c4bf72a4 100644 --- a/tests/e2e/membership.rs +++ b/tests/e2e/membership.rs @@ -233,7 +233,7 @@ fn sizing_meets_the_target_and_is_one_power_of_two_from_missing_it() { (10_000, 1e-12), (1, 0.5), ] { - let (rows, cols) = Bloom::::dimensions_for(n, p); + let (rows, cols) = Bloom::::dimensions_for(n, p).unwrap(); assert!( cols.is_power_of_two(), "n={n} p={p} gave a non-power-of-two slice of {cols}" @@ -265,7 +265,7 @@ fn sizing_meets_the_target_and_is_one_power_of_two_from_missing_it() { #[test] fn sizing_never_asks_for_more_slices_than_the_seed_list_has() { for (n, p) in [(10_000usize, 1e-7), (10_000, 1e-9), (10_000, 1e-12)] { - let (rows, cols) = Bloom::::dimensions_for(n, p); + let (rows, cols) = Bloom::::dimensions_for(n, p).unwrap(); assert!( rows <= BLOOM_MAX_SLICES, "n={n} p={p:e} asked for {rows} slices" @@ -283,9 +283,9 @@ fn sizing_never_asks_for_more_slices_than_the_seed_list_has() { } } -/// A target the seed list cannot reach within [`BLOOM_MAX_BITS`] yields the -/// widest slices that fit rather than an unbounded allocation, and a huge -/// `expected_items` does not wrap the power-of-two rounding to zero. +/// A target the seed list cannot reach within [`BLOOM_MAX_BITS`] yields +/// `None` rather than an unbounded allocation, and a huge `expected_items` +/// does not wrap the power-of-two rounding to zero. #[test] fn sizing_stays_inside_the_allocation_ceiling() { for (n, p) in [ @@ -296,7 +296,9 @@ fn sizing_stays_inside_the_allocation_ceiling() { (1_000, 0.0), (1_000, 2.0), ] { - let (rows, cols) = Bloom::::dimensions_for(n, p); + let Some((rows, cols)) = Bloom::::dimensions_for(n, p) else { + continue; + }; assert!(cols >= 1, "n={n} p={p:e} gave {cols} columns"); assert!(cols.is_power_of_two(), "n={n} p={p:e} gave {cols} columns"); assert!( @@ -311,28 +313,24 @@ fn sizing_stays_inside_the_allocation_ceiling() { } } -/// The allocation ceiling is a documented number, and the geometry a target -/// past it settles on is that number divided by the slice count and rounded -/// down to a power of two. +/// The allocation ceiling is a documented number, and a target past it has +/// no geometry. #[test] fn the_allocation_ceiling_is_the_documented_number() { assert_eq!(BLOOM_MAX_BITS, 1 << 31); - assert_eq!( - Bloom::::dimensions_for(1 << 40, 1e-9), - (20, 1 << 26) - ); + assert_eq!(Bloom::::dimensions_for(1 << 40, 1e-9), None); } -/// A degenerate sizing input yields a real geometry rather than the widest -/// slice count over one bit each, which would answer yes to everything. +/// A degenerate sizing input yields a real geometry or `None`, never the +/// widest slice count over one bit each, which would answer yes to everything. #[test] fn degenerate_sizing_inputs_give_a_usable_geometry() { - assert_eq!(Bloom::::dimensions_for(0, 0.01), (7, 2)); + assert_eq!(Bloom::::dimensions_for(0, 0.01), Some((7, 2))); + assert_eq!(Bloom::::dimensions_for(1_000, 0.0), None); assert_eq!( - Bloom::::dimensions_for(1_000, 0.0), - (20, 1 << 26) + Bloom::::dimensions_for(1_000, 2.0), + Some((1, 32)) ); - assert_eq!(Bloom::::dimensions_for(1_000, 2.0), (1, 32)); } /// The default filter is the documented geometry. diff --git a/tests/heap_memory_bytes.rs b/tests/heap_memory_bytes.rs index cf056b74..a9db6cf4 100644 --- a/tests/heap_memory_bytes.rs +++ b/tests/heap_memory_bytes.rs @@ -60,26 +60,26 @@ fn check(measured: usize, estimated: usize, churned: bool, context: &str) { } #[test] -fn estimated_memory_bytes_match_size_of_and_the_allocation() { +fn theoretical_memory_bytes_match_size_of_and_the_allocation() { // A heap of capacity 0 allocates nothing; a resident is at least one entry // and one digest. assert_eq!( - TopKHeap::::estimated_memory_bytes(0, &DataInput::U64(0)), + TopKHeap::::theoretical_memory_bytes(0, &DataInput::U64(0)), std::mem::size_of::>() ); assert!( - TopKHeap::::estimated_memory_bytes(1024, &DataInput::U64(0)) + TopKHeap::::theoretical_memory_bytes(1024, &DataInput::U64(0)) > std::mem::size_of::>() + 1024 * (std::mem::size_of::>() + 8) ); // A shape past the address space prices at `usize::MAX`. assert_eq!( - TopKHeap::::estimated_memory_bytes(usize::MAX, &DataInput::Str("key")), + TopKHeap::::theoretical_memory_bytes(usize::MAX, &DataInput::Str("key")), usize::MAX ); assert_eq!( - CountMinSketchWithHeap::, RegularPath>::estimated_memory_bytes( + CountMinSketchWithHeap::, RegularPath>::theoretical_memory_bytes( 5, 512, 1 << 61, @@ -103,7 +103,8 @@ fn estimated_memory_bytes_match_size_of_and_the_allocation() { } heap }); - let estimated = TopKHeap::::estimated_memory_bytes(heap_size, &DataInput::U64(0)); + let estimated = + TopKHeap::::theoretical_memory_bytes(heap_size, &DataInput::U64(0)); check(bytes, estimated, churned, &format!("u64 keys, {context}")); assert_eq!(heap.memory_bytes(), bytes, "u64 keys, {context}"); @@ -116,7 +117,7 @@ fn estimated_memory_bytes_match_size_of_and_the_allocation() { heap }); let estimated = - TopKHeap::::estimated_memory_bytes(heap_size, &DataInput::Bytes(&[0; 9])); + TopKHeap::::theoretical_memory_bytes(heap_size, &DataInput::Bytes(&[0; 9])); check( bytes, estimated, @@ -148,7 +149,7 @@ fn estimated_memory_bytes_match_size_of_and_the_allocation() { sketch }); let estimated = - CountMinSketchWithHeap::, RegularPath>::estimated_memory_bytes( + CountMinSketchWithHeap::, RegularPath>::theoretical_memory_bytes( rows, cols, heap_size, @@ -175,12 +176,13 @@ fn estimated_memory_bytes_match_size_of_and_the_allocation() { } sketch }); - let estimated = CountSketchWithHeap::, FastPath>::estimated_memory_bytes( - rows, - cols, - heap_size, - &DataInput::Str("key-00000000"), - ); + let estimated = + CountSketchWithHeap::, FastPath>::theoretical_memory_bytes( + rows, + cols, + heap_size, + &DataInput::Str("key-00000000"), + ); check( bytes, estimated, @@ -200,7 +202,7 @@ fn estimated_memory_bytes_match_size_of_and_the_allocation() { type CountMinI64Heap = CountMinSketchWithHeap, RegularPath>; for (rows, cols, heap_size) in [(3usize, 64usize, 7usize), (5, 2048, 100), (5, 100, 2000)] { let estimated = - CountMinI64Heap::estimated_memory_bytes(rows, cols, heap_size, &DataInput::U64(0)); + CountMinI64Heap::theoretical_memory_bytes(rows, cols, heap_size, &DataInput::U64(0)); for (encoded_keys, distinct) in [ (3usize, heap_size), (3, 50 * heap_size), @@ -232,7 +234,7 @@ fn estimated_memory_bytes_match_size_of_and_the_allocation() { // estimate once churned. for heap_size in [7usize, 1024, 2000] { let size = heap_size as u64; - let estimated = TopKHeap::::estimated_memory_bytes(heap_size, &DataInput::U64(0)); + let estimated = TopKHeap::::theoretical_memory_bytes(heap_size, &DataInput::U64(0)); let mut source = TopKHeap::::new(heap_size); for i in 0..size - 1 { source.update(&DataInput::U64(i), i as i64 + 1); @@ -284,7 +286,7 @@ fn estimated_memory_bytes_match_size_of_and_the_allocation() { ) .unwrap(); let estimated = - CountMinI64Heap::estimated_memory_bytes(rows, cols, heap_size, &DataInput::U64(0)); + CountMinI64Heap::theoretical_memory_bytes(rows, cols, heap_size, &DataInput::U64(0)); for delta in [false, true] { let context = format!("CountMinSketchWithHeap {rows}x{cols}, heap {heap_size}, delta = {delta}"); @@ -305,7 +307,7 @@ fn estimated_memory_bytes_match_size_of_and_the_allocation() { assert_eq!(sketch.memory_bytes(), bytes, "{context}"); } let estimated = - CountSketchF64Heap::estimated_memory_bytes(rows, cols, heap_size, &DataInput::U64(0)); + CountSketchF64Heap::theoretical_memory_bytes(rows, cols, heap_size, &DataInput::U64(0)); for delta in [false, true] { let context = format!("CountSketchWithHeap {rows}x{cols}, heap {heap_size}, delta = {delta}"); @@ -339,7 +341,7 @@ fn estimated_memory_bytes_match_size_of_and_the_allocation() { }); assert_eq!(sketch.memory_bytes(), bytes, "CountMinF64 {rows}x{cols}"); assert_eq!( - CountMinF64::estimated_memory_bytes(rows, cols), + CountMinF64::theoretical_memory_bytes(rows, cols), bytes, "CountMinF64 {rows}x{cols}" ); @@ -352,7 +354,7 @@ fn estimated_memory_bytes_match_size_of_and_the_allocation() { }); assert_eq!(sketch.memory_bytes(), bytes, "CountSketch {rows}x{cols}"); assert_eq!( - CountSketch::, FastPath>::estimated_memory_bytes(rows, cols), + CountSketch::, FastPath>::theoretical_memory_bytes(rows, cols), bytes, "CountSketch {rows}x{cols}" ); @@ -368,7 +370,7 @@ fn estimated_memory_bytes_match_size_of_and_the_allocation() { }; for (k, m) in [(200usize, 8usize), (64, 4)] { for n in [0usize, 1, 150, 1_000, 100_000] { - let estimated = Kll::::estimated_memory_bytes(k, m, n); + let estimated = Kll::::theoretical_memory_bytes(k, m, n); let mut half = Kll::::with_seed(k, m, 9); kll_fill(&mut half, 0..n / 2); let encoded = half.to_asapv1().unwrap(); @@ -426,7 +428,7 @@ fn estimated_memory_bytes_match_size_of_and_the_allocation() { for negatives in [false, true] { for n in [0usize, 1, 2, 3, 10_000] { let estimated = - DdSketch::estimated_memory_bytes(alpha, n, low, high, negatives); + DdSketch::theoretical_memory_bytes(alpha, n, low, high, negatives); let mut largest = 0; for order in 0..3 { for source in ["direct", "merged", "decoded"] { @@ -534,7 +536,7 @@ fn estimated_memory_bytes_match_size_of_and_the_allocation() { ), ] { assert_eq!( - HyperLogLog::::estimated_memory_bytes(precision), + HyperLogLog::::theoretical_memory_bytes(precision), bytes, "HyperLogLog p = {precision}" ); @@ -564,13 +566,13 @@ fn estimated_memory_bytes_match_size_of_and_the_allocation() { ), ] { assert_eq!( - HyperLogLogHip::estimated_memory_bytes(precision), + HyperLogLogHip::theoretical_memory_bytes(precision), bytes, "HyperLogLogHip p = {precision}" ); } assert_eq!( - HyperLogLog::::estimated_memory_bytes(200), + HyperLogLog::::theoretical_memory_bytes(200), usize::MAX ); @@ -595,7 +597,7 @@ fn estimated_memory_bytes_match_size_of_and_the_allocation() { sketch.merge(&other); sketch }); - let estimated = Kmv::estimated_memory_bytes(4_323, distinct); + let estimated = Kmv::theoretical_memory_bytes(4_323, distinct); check(bytes, estimated, repeats == 1, &context); assert_eq!(sketch.memory_bytes(), bytes, "{context}"); } @@ -617,7 +619,7 @@ fn estimated_memory_bytes_match_size_of_and_the_allocation() { let context = format!("cloned Kmv k = {k}, {cloned} cloned, distinct = {distinct}"); check( bytes, - Kmv::estimated_memory_bytes(k, distinct), + Kmv::theoretical_memory_bytes(k, distinct), true, &context, ); @@ -646,14 +648,14 @@ fn estimated_memory_bytes_match_size_of_and_the_allocation() { }); check( bytes, - Kmv::estimated_memory_bytes(k, distinct), + Kmv::theoretical_memory_bytes(k, distinct), true, &context, ); assert_eq!(sketch.memory_bytes(), bytes, "{context}"); } assert_eq!( - Kmv::estimated_memory_bytes(usize::MAX, usize::MAX), + Kmv::theoretical_memory_bytes(usize::MAX, usize::MAX), usize::MAX ); @@ -664,7 +666,7 @@ fn estimated_memory_bytes_match_size_of_and_the_allocation() { (20, 5, 512, 6), (100, 5, 2048, 3), ] { - let estimated = UnivMon::estimated_memory_bytes( + let estimated = UnivMon::theoretical_memory_bytes( heap_size, rows, cols, @@ -707,7 +709,7 @@ fn estimated_memory_bytes_match_size_of_and_the_allocation() { "CountSketchWithL2Norm {rows}x{cols}" ); assert_eq!( - CountSketchWithL2Norm::estimated_memory_bytes(rows, cols), + CountSketchWithL2Norm::theoretical_memory_bytes(rows, cols), bytes, "CountSketchWithL2Norm {rows}x{cols}" ); @@ -730,7 +732,7 @@ fn estimated_memory_bytes_match_size_of_and_the_allocation() { } sketch }); - let estimated = UnivMon::estimated_memory_bytes( + let estimated = UnivMon::theoretical_memory_bytes( heap_size, rows, cols, @@ -761,7 +763,7 @@ fn estimated_memory_bytes_match_size_of_and_the_allocation() { } sketch }); - let estimated = UnivMon::estimated_memory_bytes( + let estimated = UnivMon::theoretical_memory_bytes( heap_size, rows, cols, @@ -772,7 +774,7 @@ fn estimated_memory_bytes_match_size_of_and_the_allocation() { assert_eq!(sketch.memory_bytes(), bytes, "{context}"); } assert_eq!( - UnivMon::estimated_memory_bytes(1 << 61, 5, 512, 6, &DataInput::Str("flow")), + UnivMon::theoretical_memory_bytes(1 << 61, 5, 512, 6, &DataInput::Str("flow")), usize::MAX ); diff --git a/tests/pbt/bloom.rs b/tests/pbt/bloom.rs index 04c7e738..818fd0c4 100644 --- a/tests/pbt/bloom.rs +++ b/tests/pbt/bloom.rs @@ -322,7 +322,9 @@ proptest! { Just(f64::MIN), ], ) { - let (rows, cols) = Bloom::::dimensions_for(expected, target); + let Some((rows, cols)) = Bloom::::dimensions_for(expected, target) else { + return Ok(()); + }; prop_assert!( (1..=BLOOM_MAX_SLICES).contains(&rows),