Skip to content

feat(kll): ASAPv1 wire payload for KLL and KLLDynamic#75

Draft
GordonYuanyc wants to merge 4 commits into
mainfrom
feat/kll-payload
Draft

feat(kll): ASAPv1 wire payload for KLL and KLLDynamic#75
GordonYuanyc wants to merge 4 commits into
mainfrom
feat/kll-payload

Conversation

@GordonYuanyc

@GordonYuanyc GordonYuanyc commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

What

Self-describing ASAPv1 wire serialization for the two KLL quantile sketches, following the HLL/CMS pattern from #65:

  • compact KLL → kind_id 0x06 0x00
  • KLLDynamic → kind_id 0x06 0x01

Both share one payload [levels, items, coin]. serialize_to_bytes / deserialize_from_bytes now emit/read the ASAPv1 envelope (replacing the old rmp form), authored in per-sketch wire submodules. The nested serde Serialize/Deserialize on KLL (used by HydraCounter) is preserved unchanged.

Design decisions (docs/asapv1_wire_format.md §3.3 + Decisions)

  • Q-KLL — no hash-spec metadata. KLL never hashes (it orders raw values with total_cmp), so the hash-spec group (profile id, seed_list, …) has no truthful value. KLL metadata is structural-only: {metadata_version, k, m, item_type, seed?}, and is not HashProfile-derived.
  • item_type (f64/i64) is a metadata param, not a separate kind_id (mirrors CMS counter_type). The two variants share the payload shape and differ only by kind_id.
  • Top-most-level-first item order, byte-for-byte matching sketchlib-go's KLLState. Compact KLL grows its buffer leftward with L0 reverse-input, so its encoder reverses L0 back to input order (reusing wire_levels/wire_items); KLLDynamic stores this layout natively. Distinct from the proto KLLState path (unchanged).
  • Q-KLL-SEED — the reproducible compaction seed is recorded as the one optional metadata key. It's construction config (so metadata, not payload), present only when the sketch carries one, omitted otherwise. The payload's coin already carries the RNG's current position (enough to resume compaction); seed is what a later clear() re-seeds from, so carrying it keeps clear()-reproducibility across a serialize/restore. KLLDynamic has no seed concept and never emits the key — the variants are deliberately not forced symmetric. A consumer that doesn't use it (incl. Go) must still preserve it verbatim on re-encode.

Fail-closed decode

kind_id + metadata + item_type checks; level-layout consistency (levels[0]==0, monotonic, levels[last]==items.len(), num_levels<=61); coin remaining_bits<=64; 2 <= m <= k <= MAX_CACHEABLE_K (so crafted k/m can't drive compute_max_capacity into a huge allocation); and a weighted-count guard (so a structurally-valid but adversarial level distribution can't overflow count()/rank()/cdf() at query time). The same bound was also applied to the nested KLLWire serde path (reachable via HydraCounter).

Tests

  • Golden byte-vectors asapv1_golden/kll_f64_k200 / kll_i64_k200 (input 1..=50, seed 42, below compaction threshold → deterministic; seed now recorded in metadata) + cross-language assertions in tests/asapv1_golden.rs.
  • Round-trip (byte-stable + quantile-preserving) for both variants; empty-sketch; i64; kind_id cross-rejection; deny_unknown_fields; item-type cross-rejection; inconsistent-levels; out-of-range k/m; weighted-count overflow; nested-serde crafted-k rejection; seed present-when-seeded / omitted-when-unseeded; seed survives round-trip so clear() stays deterministic; unseeded decode + byte-stable re-serialize.
  • Full suite green; clippy --all-targets clean; fmt clean.

Review

Three independent adversarial code-review passes (subagents). Findings fixed:

  • Round 1 — HIGH: unbounded allocation from attacker-controlled k/m (ASAPv1 decoder). Fixed + regression test.
  • Round 2 — HIGH: same k/m vector still open on the nested KLLWire serde path (HydraCounter); MEDIUM: weighted-count overflow on crafted level layouts. Both fixed (harden(kll)) + regression tests.
  • Round 3 — seed change confirmed sound, no regression; closed one test-coverage gap (unseeded end-to-end decode).

Commits

  1. feat(kll) — ASAPv1 payload for KLL and KLLDynamic
  2. harden(kll) — fail closed on crafted dimensions and level layouts
  3. feat(kll) — record reproducible compaction seed in KLL metadata
  4. test(kll) — assert unseeded compact KLL decodes and round-trips byte-stable

Deferred follow-ups

  • Tumbling-window KLL is non-deterministic across processes — tracked in Tumbling-window KLL is non-deterministic across processes (unseeded init; KLLConfig has no seed) #77. tumbling.rs from_config uses unseeded KLL::init; KLLConfig has no seed. Framework-side fix, independent of serialization. (KLLDynamic is not wired into tumbling.)
  • KLLDynamic golden — needs a seeded constructor (init_kll_with_seed on KLLDynamic, an algorithm change) to be deterministic.
  • Nested KLLWire/HydraCounter serde path still drops seed — intentional; seed survives only the ASAPv1 path. Revisit if Hydra sketches need clear()-reproducibility after a round trip.
  • Portable KllSketch still double-wraps ({k, sketch_bytes} where sketch_bytes is ASAPv1). HLL's portable emits bare ASAPv1; KLL not migrated (hydra_kll nests KllSketchData). Separate cleanup.
  • sketchlib-go must mirror the 0x06 0x00/0x06 0x01 payload (incl. the optional seed key: preserve, don't interpret) and copy the kll_*.hex fixtures.

🤖 Generated with Claude Code

GordonYuanyc and others added 2 commits July 20, 2026 00:29
Add self-describing ASAPv1 serialization for the two KLL quantile
sketches, mirroring the HLL/CMS pattern: compact `KLL` (kind_id
`0x06 0x00`) and `KLLDynamic` (`0x06 0x01`) share one payload
`[levels, items, coin]`, with `serialize_to_bytes` /
`deserialize_from_bytes` living in per-sketch `wire` submodules.

KLL never hashes (it orders raw values with `total_cmp`), so its
metadata carries no hash-spec group — only the structural params
`{metadata_version, k, m, item_type}` (decision Q-KLL). `item_type`
(`f64`/`i64`) is a metadata param, not a separate kind_id (mirrors
CMS `counter_type`). Retained samples use the top-most-level-first
layout that matches sketchlib-go's `KLLState`, so the compact KLL's
leftward-grown L0 is reversed to input order on the wire.

Decode fails closed: kind_id + metadata + item_type checks, level-
layout consistency, coin `remaining_bits <= 64`, and a `2 <= m <= k
<= MAX_CACHEABLE_K` bound so crafted `k`/`m` cannot drive
`compute_max_capacity` into a huge allocation. The nested serde
`Serialize`/`Deserialize` on `KLL` (used by `HydraCounter`) is kept.

Add golden byte-vectors `kll_f64_k200` / `kll_i64_k200` (1..=50,
seed 42, no compaction — deterministic) and cross-language tests;
document the payload in docs/asapv1_wire_format.md §3.3.

KLLDynamic golden is deferred (it has no seeded constructor); the
portable `KllSketch` double-wrap and the Go mirror are follow-ups.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Second-review follow-up. Two decode-side robustness gaps:

1. The `k`/`m` bound added to the ASAPv1 decoder was missing on the
   retained nested serde `Deserialize` for `KLL` (the `HydraCounter`
   path, reachable via `Hydra::deserialize_from_bytes` and direct
   `rmp_serde::from_slice::<KLL<_>>`). A crafted `k` near `usize::MAX`
   overflowed `compute_max_capacity` / drove a huge allocation. Apply
   the same `2 <= m <= k <= MAX_CACHEABLE_K` bound plus level-layout
   validation (so the buffer math can't underflow or index OOB).

2. `validate_kll_payload` (both ASAPv1 variants) accepted a
   structurally-valid but adversarial level distribution — e.g. many
   items parked at a high compactor level — that decoded fine but then
   overflowed the weighted `count()`/`rank()`/`cdf()` (`size * 2^h`) at
   query time. Add a shared `checked_weighted_count` guard so decode
   rejects any layout whose weighted count would overflow; a live
   sketch's weighted count equals its ingested item count, so real
   bytes are never rejected.

Adds regression tests for both (nested-serde crafted `k`, ASAPv1
weighted-count overflow). Full suite + hydra green; clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GordonYuanyc and others added 2 commits July 20, 2026 02:01
Add an optional `seed` key to the KLL ASAPv1 metadata so a decoded
sketch keeps clear()-reproducibility instead of falling back to
wall-clock. It's construction config, so it lives in the metadata
(config→metadata rule), and it's the first optional key in v1:
present only when the sketch carries a seed (Some), omitted otherwise
(skip_serializing_if + default). The payload's `coin` already carries
the RNG's current position (enough to resume compaction); `seed` is
what a later clear() re-seeds from.

Scope: compact KLL populates and restores it. KLLDynamic has no seed
concept and always omits the key — the two variants are deliberately
not forced to be symmetric here. A consumer that doesn't use the key
(including Go) must still preserve it verbatim on re-encode.

Regenerate the kll_f64/kll_i64 goldens (metadata gains seed=42) and
add tests: seed present-when-seeded / omitted-when-unseeded, and that
it survives a round trip so a decoded sketch's clear() stays
deterministic. Document as decision Q-KLL-SEED.

Relates to determinism gap tracked in #77 (tumbling KLL uses unseeded
init) — that fix is separate (framework-side).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…table

Closes a coverage gap from the third review: existing positive
round-trip tests all used init_kll_with_seed, so the seed-absent
metadata path was exercised on encode but never fully decoded and
re-serialized. Add an unseeded decode + byte-stable re-serialize
assertion.

Co-Authored-By: Claude Opus 4.8 (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.

1 participant