Skip to content

fix(univmon_optimized): unbiased distinct count and direct L2 for UnivMonPyramid - #222

Closed
zzylol wants to merge 1 commit into
fix/univmon-certifiable-estimatorsfrom
fix/univmon-pyramid-estimators
Closed

zzylol wants to merge 1 commit into
fix/univmon-certifiable-estimatorsfrom
fix/univmon-pyramid-estimators

Conversation

@zzylol

@zzylol zzylol commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

Stacked on #221 (fix/univmon-certifiable-estimators) and targets that branch: terminal-mode calc_l2 uses L2HH::get_l2_sqr(), which #221 adds. It can be retargeted to main once #221 merges.

Why

UnivMonPyramid (src/sketch_framework/univmon_optimized.rs) has the same two estimator problems #221 fixes in UnivMon, so its distinct count and L2 norm carried no error bound that held:

  • calc_card dropped heap keys by their Count Sketch estimate, which biases the distinct count.
  • calc_l2 went through the heap recursion, with more variance than the Count Sketch's own F2 estimate and no stated bound.

What

  • calc_card counts every heap key, in standard and terminal mode. Entropy and other g keep their filters.
  • calc_l2 returns layer 0's Count Sketch L2 in standard mode, and sqrt of the summed per-stratum squared estimates in terminal mode.
  • New UnivMonPyramid::top_layer_complete(), the precondition of the distinct-count bound.
  • Doc comments on calc_l2 and calc_card state the (eps, delta) bounds for the pyramid and refer to UnivMon's derivations from fix(univmon)!: every query is the paper's Algorithm 2 G-sum #221. docs/api/api_univmon_optimized.md and docs/tests.md are updated. No CHANGELOG entry.

How

  • calc_g_sum (and the terminal-mode calc_terminal_g_sum) take is_card and, when it is set, skip neither the count > 0 filter nor the heavy_threshold filter. This is the same change fix(univmon)!: every query is the paper's Algorithm 2 G-sum #221 makes in UnivMon.
  • In standard mode layer 0 receives every key, so its Count Sketch estimates F2 directly (each row's sum of squared counters, median across rows). In terminal mode the layers hold disjoint strata, so their F2 estimates add.
  • The bounds in the pyramid's terms:
    • Standard-mode L2 uses layer 0's dimensions (the elephant ones whenever elephant_layers >= 1): delta = P(Bin(r, p) >= ceil(r/2)) with p = 2q/eps^2. Terminal mode holds each stratum to its own layer's dimensions and union-bounds over the non-empty strata.
    • The distinct-count bound does not depend on the sketch dimensions, so the elephant/mouse split does not enter it. Given a complete top layer the estimate is unbiased with variance at most 2 F0^2/k, so P(|Y_0 - F0| > eps F0) <= 2/(k eps^2) for k = heap_size >= 8.

Before this PR

  • A heap key whose Count Sketch estimate was not positive was left out of the distinct count. So was a key below L2/sqrt(k) on an incomplete layer. For example, a one-layer 1x1 pyramid holding a weight-5 key and a weight-1 key of opposite sign returned calc_card() == 1.
  • With a 5x64 layer 0, calc_l2()^2 missed the true F2 by more than 50% in 39 of 150 Zipf trials, where the Count Sketch bound allows delta ~ 0.016.

After this PR

  • The same pyramid returns calc_card() == 2. The distinct count is unbiased whenever top_layer_complete() holds, and callers can check that.
  • calc_l2() equals layer 0's direct estimate and stays within the documented bound in the Monte Carlo test.

Verification

Each fix has a deterministic test that fails before it and passes after it:

  • pyramid_calc_card_counts_heap_keys_with_nonpositive_estimates
  • pyramid_calc_l2_reads_layer_zero_directly_in_standard_mode
  • pyramid_calc_l2_sums_stratum_estimates_in_terminal_mode

pyramid_top_layer_complete_tracks_the_top_heap covers the new accessor.

Monte Carlo tests run in under a second by default. Their _long variants are #[ignore]d.

  • pyramid_calc_l2_meets_its_documented_bound: 150 trials, or 3000 in the long variant. It failed before the fix (39/150 misses) and passes after it.
  • pyramid_calc_card_meets_its_documented_bound: 60 trials, or 2000 in the long variant, with 3x256 elephant and 2x32 mouse layers. It checks the miss rate against the Chebyshev bound, that the variance is at most 2 F0^2/k, and that the estimate is unbiased (mean error within four standard errors).

One e2e assertion is adjusted. tests/e2e/frameworks.rs::univmon_pyramid_weighted_metrics checked the distinct count against a 20% band with with_defaults() (heap 32). At heap 32 the unbiased estimator's standard deviation can reach sqrt(2/32) = 25% of F0. On that test's stream the estimate came out at -26%; the old filtered estimator happened to fall inside the band. Following what #221 did for UnivMon's documented matrix, the test now uses the default tiers with heap 128 and asserts top_layer_complete(). It passes.

Run for this PR:

  • cargo fmt --check
  • cargo clippy --workspace --all-targets -D warnings with all features, default features, octo-runtime and experimental
  • cargo doc --all-features with -D warnings
  • cargo test --lib with the same four feature sets
  • the pyramid tests including the ignored long variants

The full e2e suite (tests/e2e) was not run for this PR. The only e2e test run was univmon_pyramid_weighted_metrics.

Evidence types: screenshots and performance measurements do not apply. Before/after behavior comes from the tests above.

Limitations

…vMonPyramid

UnivMonPyramid had the same two estimator problems as UnivMon:
- calc_card dropped heap keys whose estimate was not positive, or below
  L2/sqrt(k) on an incomplete layer.
- calc_l2 ran the heap recursion with g = x^2 instead of reading the
  Count Sketch.

calc_card now counts every heap key. calc_l2 reads layer 0's Count
Sketch in standard mode and sums the strata's squared estimates in
terminal mode.

Adds top_layer_complete(). The doc comments state the bounds in the
pyramid's own terms:
- L2 uses layer 0's dimensions in standard mode, and each stratum's own
  in terminal mode.
- The distinct-count bound is independent of the elephant/mouse split.

Before the fix, a 5x64 layer 0 missed by more than eps = 0.5 in 39 of
150 Zipf trials, against delta = 0.016. The direct estimate stays within
the bound. The Monte Carlo checks have ignored long variants.

The e2e univmon_pyramid_weighted_metrics test checked the distinct
count within 20% at heap 32, where the unbiased estimator's standard
deviation can reach 25% of F0. It now uses the default tiers with heap
128 and asserts top_layer_complete(), as #221 did for UnivMon's
documented matrix.
@zzylol

zzylol commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

Closing: UnivMonPyramid (univmon_optimized.rs) was removed from main (#240).

@zzylol zzylol closed this Oct 20, 2026
GordonYuanyc added a commit that referenced this pull request Oct 20, 2026
…223, #228, #229, #233, #238) (#242)

* feat(univmon): check_shape refuses a pyramid shape without building it

`UnivMon::check_shape(heap_size, rows, cols, layers)` accepts exactly the
shapes `UnivMon::new` builds: positive dimensions, at most UNIVMON_MAX_LAYERS
layers, at most MATRIX_MAX_ROWS rows, rows within the 128-bit hash budget and
a counter count that fits usize. `new` panics exactly when it fails, so it no
longer builds a pyramid the ASAPv1 wire refuses.

Ports #229.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(univmon): count every cardinality candidate, direct L2, documented error bounds

`estimate_cardinality` counts every heap key, including zero-weight keys and
keys whose Count Sketch estimate is not positive. `estimate_l2` reads layer 0's
Count Sketch F2 estimate in standard mode and sums the strata's F2 estimates in
terminal mode; `estimate_g_sum` keeps the paper's recurrence, and its flag is
now `count_every_key` (`true` counts every heap key). New
`top_layer_complete()`. The L2 bound and the distinct-count bound, stated
jointly with `top_layer_complete()`, are derived in docs/api/api_univmon.md,
with Monte Carlo tests.

Ports #221.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(univmon_two_tier): count every cardinality candidate and direct L2

`UnivMonTwoTier` gets the estimators `UnivMon` has: `estimate_cardinality`
counts every heap key whatever its estimate, zero-weight keys included;
`estimate_l2` reads layer 0's Count Sketch F2 estimate in standard mode and
sums the strata's in terminal mode; new `top_layer_complete()`. The API doc
states the bounds in the two-tier layout's terms, with Monte Carlo tests.

Ports #222.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(hydra): group-total query from per-cell mass

`Hydra::query_group_total(key)` returns a subpopulation's estimated total
mass: the minimum over rows of the subkey's cell `HydraCounter::total_mass()`,
a Count-Min cell's inner row sum or a KLL cell's retained weight. Count Sketch,
HyperLogLog and UnivMon counters return an error. The total is read from
existing cell state, so the wire format is unchanged. `query_key` and the new
query share one subkey hash helper.

Ports #223.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(kll): allocate the item buffer as items arrive

Kll's item buffer starts empty, then holds 16 items and doubles up to
max_capacity; a full buffer compacts only at max_capacity, so retained
items, coin tosses, quantiles, merges and ASAPv1 bytes match the eagerly
allocated buffer for the same input and seed. The merge scratch buffer
starts empty; merge, from_portable_state and the ASAPv1 decoder size the
buffer to what they hold.

lazy_allocation_matches_eager compares every level and the coin with a
test-only eager copy (k = 8 to 1024, up to 50,000 items, with a merge).

Ports #238.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(kll): Kll::memory_bytes and KllDynamic::memory_bytes

Both report the bytes the sketch holds now: Kll its struct, item buffer,
level array, merge scratch space and memoized CDF; KllDynamic its struct,
item buffer and level bounds.

Ports #238.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(kmv): estimate and merge by shared reference, memory_bytes, early reject past the k-th hash

Kmv::estimate takes &self and Kmv::merge takes &Kmv. Kmv::memory_bytes
is the struct plus the hash slots the heap has allocated
(CommonHeap::allocated_slots, crate-private). A full sketch turns away a
hash at or above its largest retained one before scanning the retained
hashes for a duplicate; the retained set and every estimate are unchanged.

Ports #228.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(heap)!: TopKHeap::estimated_memory_bytes; UnivMonQ::memory_bytes

TopKHeap::estimated_memory_bytes(heap_size, key) is the bytes a heap of
that capacity holds in steady state once keys shaped like key churn
through it: the struct, entry and digest arrays, the digest index (std
HashMap layout, grown once more when tombstones force a resize of an index
over half full) and the owned key bytes. A heap holds at most this.

UnivMonQ::estimated_memory_bytes(&self) becomes memory_bytes(&self) and
counts what the instance holds: its counters, candidate tables (bucket
count from the largest capacity() seen), candidate heaps and ordered-sample
heap.

tests/heap_memory_bytes.rs checks both against a counting global
allocator: the heap estimate as an upper bound after a fill and exactly
after churn, UnivMon-Q exactly.

Ports #233 (the heap half).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(cms,cs)!: check_shape, clear and memory accounting for the matrix sketches

CountMinSketch, CountSketch and their WithHeap and WithSpaceSaving forms
gain check_shape(rows, cols), which accepts the ASAPv1 geometry,
1 <= rows <= MATRIX_MAX_ROWS and 1 <= cols <= u32::MAX, on every hashing
path. with_dimensions, new and from_storage panic on any other shape. Past
20 rows the regular path and the fast path's per-row (Rows) layout hash
row r + 20 with row r's seed; the packed layouts reuse no seed. Tests that
built such a shape to watch the encoder refuse it check check_shape
instead, Hydra's included.

clear() zeroes the counters (and empties the heap for the WithHeap types)
and returns the Nitro sampler to its start (Nitro::reset, through the new
MatrixStorage::reset_sampler), keeping shape, allocation and admission
rate.

TopKHeap::memory_bytes reports the bytes a heap holds, taking the digest
index's bucket count from the largest capacity() it has reported, which
evictions' tombstones cannot lower. The WithHeap types add memory_bytes
and estimated_memory_bytes(rows, cols, heap_size, key).
tests/heap_memory_bytes.rs checks all of them against a counting
allocator, after a fill and after 50 times heap_size evicting keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(hash)!: a seed for every seed index; matrix sketches take any row count

Seed index i hashes with seed_for_index(i) = SEED_LIST[i % 20] *
(i / 20 + 1) + i / 20 in wrapping u64 arithmetic; indexes below 20 keep
their seed. The matrix hash passes seed indexes through unreduced, so rows
past 20 on the regular path and the fast path's per-row layout hash
independently, and UnivMon-Q's occurrence priorities hash at their own seed.

check_shape on Count-Min, Count Sketch and their WithHeap and
WithSpaceSaving forms refuses only a zero dimension and a counter count past
usize; Elastic, Hydra and UnivMon build any row count. to_asapv1 and
from_asapv1 keep the MATRIX_MAX_ROWS bound.

The UnivMon-Q residual-CDF accuracy test hashes each of its 32 sketches at
its own seed index.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(cms,cs)!: memory_bytes, CountMinF64 at the root, check_shape without type parameters

CountMinSketch and CountSketch gain memory_bytes(&self): the struct plus
its rows * cols counters, exact for Vector2D storage, checked against a
counting allocator in tests/heap_memory_bytes.rs. CountMinF64 is
re-exported from sketches and the crate root.

check_shape on CountMinSketch, CountSketch, their WithHeap and
WithSpaceSaving forms is defined on the type with its default parameters,
so CountMinSketch::check_shape(3, 64) resolves without naming storage,
path or hasher. A call naming non-default type parameters no longer
compiles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(asapv1)!: seed_derivation is "seed_list_index_scaled"

The ASAPv1 seed_derivation string names seed_for_index. Bytes carrying
"seed_list_index_wrap" no longer decode. The golden submodule moves to the
regenerated fixtures (sketchlib-golden-bytes#3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: zz_y <zeyingz@umd.edu>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant