Skip to content

Export impl_hll_bucket_list! so callers can build any HLL precision - #95

Open
GordonYuanyc wants to merge 6 commits into
mainfrom
fix/94-export-hll-bucket-list-macro
Open

Export impl_hll_bucket_list! so callers can build any HLL precision#95
GordonYuanyc wants to merge 6 commits into
mainfrom
fix/94-export-hll-bucket-list-macro

Conversation

@GordonYuanyc

@GordonYuanyc GordonYuanyc commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Closes #94 (part (a) only — see Not in scope below).

What

Six commits:

  1. bd558fc — export the macro. Mark impl_hll_bucket_list! #[macro_export].
    Downstream crates can now generate an HllRegisterStorage type at any
    precision (e.g. lg_k = 18, which sketch-bench's lkarger search space
    needs) instead of being stuck with the built-in HllBucketListP12/P14/P16.
    The generated type is the same monomorphized fast path, not a slow fallback.

  2. c614acd — test it. tests/hll_custom_precision.rs.

  3. debe577 — make ErtlMLE::estimate generic, so custom precisions can
    query, not just insert and merge.

  4. f60317d — heap-allocate register storage, so large precisions don't
    overflow the stack in debug builds.

  5. 72c190e — harden the macro against caller context (from review): qualify
    Result/Box/Default/unreachable!/derive paths, and assert
    num_registers == 1 << precision at compile time.

  6. 02ec700 — tighten the tests (from review): non-vacuous lg_k=4 tolerance,
    ASAPv1 envelope round-trip for all three variants at a custom precision.

asap_sketchlib::impl_hll_bucket_list!(HllBucketListP18, 18, 1_usize << 18);

let mut hll = HyperLogLogImpl::<Classic, HllBucketListP18>::new();

Commits 3 and 4 came out of checking the full variant × operation matrix at a
custom precision. Result after this PR — every cell works:

variant insert query merge ser/de
Classic
ErtlMLE ✓ (was ✗, commit 3)
HIP n/a — no merge method, by design

Commit 1 — note on "one line"

#[macro_export] is the one functional line, but it can't ship alone: the macro
body referenced Serialize, Serializer, Deserialize, Deserializer,
serde_big_array::BigArray, Index, IndexMut, Range and
HllRegisterStorage by bare name, so the expansion only compiled inside this
module. The rest is mechanical path-qualification — $crate::__private::serde,
$crate::HllRegisterStorage, ::std::ops::* — exactly the pattern
impl_fixed_matrix! already uses (748f179). Without it a downstream caller
would have to import all eight names and take a serde-big-array dependency of
their own.

Also in commit 1: a doc example on the macro (runs as a doctest, i.e. from
outside the crate), a CHANGELOG entry, and one bullet in docs/features.md
next to impl_fixed_matrix!.

Commit 3 — ErtlMLE at custom precisions

estimate for ErtlMLE came from impl_ertl_mle_estimate!, instantiated only
for P12/P14/P16, so a custom-precision sketch could insert and merge but
estimate did not exist:

error[E0599]: no method named `estimate` found for struct `HyperLogLogImpl<ErtlMLE, P18>`

The only thing forcing the macro was the exact-sized histogram
[u32; REGISTER_BITS + 2], unwritable in a generic impl while
generic_const_exprs is unstable. It doesn't need to be exact: REGISTER_BITS + 2
peaks at 66, so one [u32; 66] covers every precision and the loop bounds stay
Registers::REGISTER_BITS — still a compile-time constant per monomorphization.
The extra slots are zeros the algorithm never reads, so estimates at existing
precisions are unchanged. Net −4 lines; also drops the two #[cfg(test)]
harness macros that only existed to work around the same limitation.

Commit 4 — the debug stack overflow

Default built [0_u8; N] on the stack and copied it into the box; Deserialize
did the same via BigArray. Release elides those temporaries — debug does not.
On a 2 MiB test thread:

operation before (debug) after (debug)
default() / new() aborts at lg_k ≥ 22 fine (no stack ceiling; tested to lg_k 30 / 1 GiB)
rmp_serde::from_slice aborts at lg_k ≥ 17 fine
deserialize_from_bytes (ASAPv1) fine fine

Not a new bug — it's equally true of the shipped HllBucketListP16 — but
exporting the macro makes the precisions where it bites reachable, and
fatal runtime error: stack overflow is a bad first experience for someone who
just generated a P18 type. Fix routes both paths through a heap allocation that
never names an [u8; N] value, so behavior no longer depends on the optimizer.

Wire compatibility: unchanged. Serialize still uses BigArray; the new
visitor reads the same fixed-length sequence via deserialize_tuple. Verified
directly — bytes written by the pre-change implementation decode to identical
registers under the new one and re-encode byte-for-byte (16388 bytes, P14).

Review follow-ups (commits 5–6)

A review pass turned up two real defects in the exported macro, both reproduced
before fixing and re-verified after:

  • Unqualified Result broke the expansion in ordinary caller modules. With
    use std::io::Result or use std::fmt::Result in scope — the aliases take a
    different number of generic arguments — the macro failed with 8 errors
    (E0053/E0107/E0277/E0308). Commit 1 had qualified serde, std::ops, vec!
    and write! but missed Result in three signatures, plus Box, Default
    and unreachable!. Now verified against a downstream crate that shadows
    Result two ways and declares its own Default trait, under deny(warnings).

  • Nothing tied precision to num_registers.
    impl_hll_bucket_list!(Foo, 18, 1_usize << 20) compiled and silently produced
    wrong estimates forever: the bucket index is (hash >> REGISTER_BITS) & P_MASK,
    so only 1 << 18 registers are ever reachable while estimate() divides by
    1 << 20. Measured on the broken pair: 83,131 of 1,048,576 registers ever
    touched. No panic, no bounds violation — it just returns bad numbers. A
    const _: () block now rejects it at compile time:

    error[E0080]: evaluation panicked: impl_hll_bucket_list!: num_registers must equal 1 << precision
    

    precision >= 1 is checked the same way, and HllRegisterStorage now
    documents the relations hand-written impls must uphold.

The review also independently confirmed the PR's three load-bearing claims:
ErtlMLE estimates byte-identical across P12/P14/P16 × 8 cardinalities; MessagePack
output byte-identical (16388 B, cmp clean, and bytes cross-decode both
directions); insert throughput unchanged.

Known, deliberately not fixed here: impl_fixed_matrix! in the same file has
the identical bare-Result flaw (pre-existing, and it is already exported). It
deserves its own pass over all of its unqualified names rather than a partial fix
smuggled into this PR.

Test coverage

tests/hll_custom_precision.rs is an integration test, so it compiles as a
separate crate against the public surface only — invoking the macro there is
itself the check that the export works downstream. It instantiates lg_k
4, 8, 10, 13, 18, and 22 (the last only in the allocation test) and asserts:

  • storage constants (PRECISION, NUM_REGISTERS, REGISTER_BITS, P_MASK,
    len, zeroed on default())
  • Classic, ErtlMLE, and HIP estimates within tolerance at every precision,
    over deterministic inputs (so the assertions can't flake); tolerances are ~4x
    the 1.04/sqrt(m) standard error, and measured errors sit well inside them
    (e.g. lg_k=18: classic 0.01%, ErtlMLE 0.01%, HIP 0.15%)
  • register ranks stay within REGISTER_BITS + 1
  • merge of two overlapping lg_k=18 sketches recovers the union cardinality
  • Index/IndexMut for usize and Range, and IntoIterator
  • MessagePack round-trip at lg_k=13, and an ASAPv1 envelope round-trip at a
    custom precision for all three variants — including HIP's running scalars,
    plus a negative case (lg_k=18 bytes must not decode as lg_k=13)
  • stack regression at lg_k 18 and 22 — this test aborts without commit 4

cargo test and cargo test --release both fully green (505 unit +
6/7/12/4/1 integration + 21 doctests). cargo fmt --check and
cargo clippy --all-targets clean.

Not in scope

Part (b), the Vec<u8> runtime-sized fallback, is not here. Worth a separate
PR — with (a) in place, every precision is reachable, so (b) becomes a
convenience (no type declaration needed up front) rather than the only way to
run at lg_k=18. Note the portable wire type message_pack_format::portable::HllSketch
already carries a runtime precision, so there's prior art for the shape.

🤖 Generated with Claude Code

GordonYuanyc and others added 6 commits August 19, 2026 15:11
`HllRegisterStorage` types could only come from the three precisions the
crate instantiates (lg_k 12/14/16); a caller needing another precision
(e.g. lg_k=18) had no way to build one at all.

Mark `impl_hll_bucket_list!` `#[macro_export]` so downstream crates can
generate their own storage type and keep the same monomorphized fast
path. As with `impl_fixed_matrix!`, the expansion is now fully path-
qualified (`$crate::__private::serde`, `$crate::HllRegisterStorage`,
`::std::ops::*`) so it compiles in a caller that has not imported those
names or taken a serde dependency.

Refs #94

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`tests/hll_custom_precision.rs` compiles as a separate crate, so it only
sees the public surface — invoking `asap_sketchlib::impl_hll_bucket_list!`
there is a real check that the export works downstream.

Generates storage at lg_k 4, 8, 10, 13, and 18 (the precision sketch-bench's
`lkarger` search space needs) and asserts the storage constants, Classic and
HIP estimates within tolerance, merge at lg_k=18, index/range/iterator
access, and a MessagePack round-trip.

Refs #94

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`estimate` for the ErtlMLE variant was generated by `impl_ertl_mle_estimate!`
and instantiated only for P12/P14/P16, so a custom-precision storage type
could insert and merge an ErtlMLE sketch but never query it.

The only thing forcing the macro was the exact-sized histogram,
`[u32; REGISTER_BITS + 2]`, which cannot be written in a generic impl while
`generic_const_exprs` is unstable. It does not need to be exact:
`REGISTER_BITS + 2` peaks at 66, so a single `[u32; 66]` covers every
precision and the loop bounds stay `Registers::REGISTER_BITS` — still a
compile-time constant in each monomorphization. The slots past the end are
zeros the algorithm never reads, so estimates at the existing precisions are
bit-identical.

Drops the macro and the two `#[cfg(test)]` harness macros that only existed
to work around it. Extends the custom-precision test to assert ErtlMLE
accuracy alongside Classic and HIP.

Refs #94

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Default` built `[0_u8; NUM_REGISTERS]` as a stack value and copied it into
the box, and `Deserialize` did the same with `BigArray`'s fixed array.
Release builds elide those temporaries, but debug builds do not: on a 2 MiB
test thread, deserializing overflowed the stack at lg_k >= 17 and
`default()` at lg_k >= 22. That was survivable while the crate only shipped
lg_k <= 16 (64 KiB), but exporting the macro makes the affected precisions
reachable, and a hard abort is a poor failure mode for `cargo test`.

`Default` now goes through a zeroed boxed slice, and `Deserialize` fills a
heap-allocated array through a visitor instead of `BigArray::deserialize`.
Neither path ever names an `[u8; N]` value, so stack use is constant and no
longer depends on the optimizer.

The encoding is untouched -- `Serialize` still uses `BigArray`, and the
visitor reads the same fixed-length sequence via `deserialize_tuple`. Bytes
written by the previous implementation decode to identical registers and
re-encode byte-for-byte.

Adds a regression test at lg_k 18 and 22; it aborts with a stack overflow
without this change.

Refs #94

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two problems a code review turned up, both specific to the macro now being
public API.

Hygiene: the expansion still named `Result`, `Box`, `Default` and
`unreachable!` bare. `Result` is the one that bites -- `use std::io::Result`
or `use std::fmt::Result` at module scope is ordinary, and either makes the
expansion fail with eight errors, since the aliases take a different number
of generic arguments. Qualified those, plus the `Clone`/`Debug` derive paths.
Verified against a downstream crate that shadows `Result` two ways and
declares its own `Default` trait.

Invariant: nothing tied the second and third arguments together, so
`impl_hll_bucket_list!(Foo, 18, 1_usize << 20)` compiled and produced a
sketch that addresses `1 << 18` buckets while dividing by `1 << 20` -- every
estimate silently wrong, no panic, no bounds violation (measured: 83131 of
1048576 registers ever reachable). A `const _: ()` block now asserts
`num_registers == 1 << precision` and `precision >= 1` at compile time, and
`HllRegisterStorage` documents the same relations for hand-written impls,
which the estimators also depend on.

The sibling `impl_fixed_matrix!` has the same bare-`Result` flaw. Left alone
here: it is pre-existing, unrelated to this issue, and deserves its own pass
over all of its unqualified names.

Refs #94

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review follow-ups on the test file:

- The lg_k=4 tolerance was 1.10, so an estimator returning zero would have
  passed (relative error 1.0). Tightened to 0.60; the measured worst case at
  that precision is 0.43, and every tolerance is now below 1.0.
- Added an ASAPv1 envelope round-trip for all three variants at a custom
  precision. That is a different code path from the derive-based serde impls
  the file already covered -- it writes `precision` into the metadata and
  validates register length on decode -- and it is the cross-language format,
  so it is the one worth guarding. Includes HIP, whose running scalars have
  to survive the round-trip, and a negative case: lg_k=18 bytes must not
  decode as lg_k=13.
- Reworded the module doc, which claimed no serde dependency while calling
  `rmp_serde`. The real point is that it imports none of the names the macro
  expansion needs.

Also moves the heap-allocation CHANGELOG entry from Added to Fixed, where a
bug fix belongs.

Refs #94

Co-Authored-By: Claude Opus 5 (1M context) <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.

HLL register storage only supports lg_k in {12, 14, 16} — add a wider/fallback path

1 participant