Repository navigation
feat(sketch): posterior (query-time) error estimation for CMS/CU-Sketch/Count-Sketch (Chen et al. IMC'21) - #248
Conversation
|
Reorganized per feedback: No behavior change — public paths ( |
|
A code-review pass I ran (mistakenly targeting this PR's content while I asked it to review a different PR) surfaced 6 real findings against this branch. Verified each and fixed the legitimate ones:
All algorithmic changes verified output-preserving via the existing pinned/property tests (only the one test pinning the old degenerate-input bug needed its expected values updated). |
…. IMC'21 Part of #239 — implements the paper's Algorithm 1 (CM-Sketch), Algorithm 2 (CU-Sketch, identical to Algorithm 1 per the paper's own text), and Algorithm 3 (Count-Sketch, median-based, odd rows only per Theorem A.1) as pure, sketch-object-agnostic functions in a new asap_types::post_asap::error_estimation module, plus the traditional a priori bound/sizing for comparison. Confirmed this repo has no CMS/CountSketch/CU-Sketch runtime and no counter-array data structure anywhere — SketchKind/SketchParams (post_asap/sketch.rs) and default_size_params/cms_width/cms_depth (asap-aware-mapping/boundary.rs) are purely planning-time sizing metadata. So the new estimator functions take plain counter slices, not a concrete sketch type, ready for a future real sketch runtime's readout path to call directly — no runtime wiring is attempted here. Also adds boundary.rs::posterior_aware_size_params, an opt-in alternative to default_size_params that shrinks CMS-family width under an explicit caller-stated ExpectedCaseSizing assumption, documenting exactly what guarantee is being traded away. default_size_params itself is untouched. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Moves crates/types/src/post_asap/error_estimation.rs into a new post_asap::query_time/ submodule with its own module doc, per review feedback: the posterior sketch error estimator (issue #239) is execution-time code (it needs a real sketch's actual counter values, readable only once a query has run against real data), architecturally distinct from the rest of post_asap, which is planning-time IR the planner constructs before a query runs. That distinction previously lived only in prose (crate doc's "no execution logic lives in this workspace... error_estimation is the one exception"); it's now visible in the directory listing itself. No behavior change — public re-export paths (asap_types::post_asap::cms_posterior_error_bound etc.) are preserved via query_time/mod.rs's re-exports, so this is a pure move. Updates the doc-path references in crates/types/src/lib.rs and crates/asap-aware-mapping/src/boundary.rs accordingly, and boundary.rs's ExpectedCaseSizing/posterior_aware_size_params docs now cross-reference issue #250 (filed separately: feeding query-time-observed posterior error back into a future replan, rather than a bare caller-supplied width_relaxation guess). Verified: cargo build --workspace --all-targets, cargo test --workspace (459 passed, 0 failed), cargo fmt --all -- --check, cargo clippy --workspace --all-targets --all-features -- -D warnings. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Findings surfaced while reviewing a different PR (the tool mistakenly reviewed this branch's content), verified and fixed here since this PR is still open: - classic_cms_sizing didn't replicate boundary.rs's cms_width/cms_depth saturating clamps, only their in-range formula, despite the doc comment's "identical" claim - silently diverging outside a narrow (eps, delta) range (e.g. eps=1e-10 or delta=1e-20), and returning (0, 0) for degenerate inputs instead of boundary.rs's real saturate-to-hi behavior. Now shares the exact same saturating_ceil clamp policy. New tests pin the extreme-range values by hand. - posterior_aware_size_params's wildcard match arm silently gave any future SketchKind variant worst-case sizing with no compiler signal, unlike default_size_params's exhaustive match. Spelled out exhaustively to match. - relaxed_width's inline clamp reimplemented boundary.rs's own private saturating_ceil byte-for-byte instead of calling it - now calls it. - count_sketch_posterior_error_bound's fractile search was O(w*r^2) (linear scan over w fractiles, each recomputing an O(r) binomial coefficient from scratch): now O(r log w) via binary search (the tail probability is monotonic in the fractile) over coefficients precomputed once. - kth_largest fully sorted (O(w log w)) to find one rank: now uses select_nth_unstable_by (O(w) average). - query_time/mod.rs's re-export of error_estimation's public functions is now a glob, not a hand-maintained list, so a future function only needs adding to post_asap/mod.rs's curated public-surface list, not two places. All algorithmic changes preserve identical output - verified via the existing pinned-value and property-based tests, none of which needed their expected values changed (only the degenerate-input test, which was pinning the bug). Verified: cargo build --workspace --all-targets, cargo test --workspace (460 passed, 0 failed), cargo fmt --all -- --check, cargo clippy --workspace --all-targets --all-features -- -D warnings. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
10c11a9 to
c2ecc53
Compare
…e_cont do The DDSketch kernel read sketchlib's nearest rank ceil(q n). PromQL quantile_over_time and SQL percentile_cont interpolate linearly between the order statistics at ranks floor(q (n - 1)) and ceil(q (n - 1)), so answers missed the exact ones by more than alpha: [20, 40] at q = 0.9 read 40 against 38. The kernel now reads DdSketch::quantile_interpolated (sketchlib #248). An empty sketch still gives no answer, and a q outside [0, 1] (NaN included) is still an error; the Planner never sends one, since Pass 1 prunes the sketch candidate (QuantileOutsideUnitInterval) and the exact plan answers +-Inf. The Planner's DDSketch rule (ddsketch_relative_error_alpha_v1) states alpha relative error on the value read and assumes no rank convention; the interpolated read is within alpha when both neighbours share a sign, which the sign rule already requires before the bound composes. The bound is unchanged. The two integration tests that rebuilt interpolation from nearest-rank reads now call quantile_interpolated.
…e_cont do The DDSketch kernel read sketchlib's nearest rank ceil(q n). PromQL quantile_over_time and SQL percentile_cont interpolate linearly between the order statistics at ranks floor(q (n - 1)) and ceil(q (n - 1)), so answers missed the exact ones by more than alpha: [20, 40] at q = 0.9 read 40 against 38. The kernel now reads DdSketch::quantile_interpolated (sketchlib #248). An empty sketch still gives no answer, and a q outside [0, 1] (NaN included) is still an error; the Planner never sends one, since Pass 1 prunes the sketch candidate (QuantileOutsideUnitInterval) and the exact plan answers +-Inf. The Planner's DDSketch rule (ddsketch_relative_error_alpha_v1) states alpha relative error on the value read and assumes no rank convention; the interpolated read is within alpha when both neighbours share a sign, which the sign rule already requires before the bound composes. The bound is unchanged. The two integration tests that rebuilt interpolation from nearest-rank reads now call quantile_interpolated.
…n sketchlib planner-pin/0.4.0-plus (#803) * chore: pin asap_sketchlib to planner-pin/0.4.0-plus (63e5655) Release 0.4.0 (eb04e7f) plus the open sketchlib PRs #245 (check_weight, try_update_weighted, rescore_heap), #246 (KLL lazy-buffer follow-ups), #247 (Count Sketch stack medians) and #248 (DdSketch::quantile_interpolated). Moves to a release once they merge. * fix(executor): DDSketch quantiles interpolate as PromQL and percentile_cont do The DDSketch kernel read sketchlib's nearest rank ceil(q n). PromQL quantile_over_time and SQL percentile_cont interpolate linearly between the order statistics at ranks floor(q (n - 1)) and ceil(q (n - 1)), so answers missed the exact ones by more than alpha: [20, 40] at q = 0.9 read 40 against 38. The kernel now reads DdSketch::quantile_interpolated (sketchlib #248). An empty sketch still gives no answer, and a q outside [0, 1] (NaN included) is still an error; the Planner never sends one, since Pass 1 prunes the sketch candidate (QuantileOutsideUnitInterval) and the exact plan answers +-Inf. The Planner's DDSketch rule (ddsketch_relative_error_alpha_v1) states alpha relative error on the value read and assumes no rank convention; the interpolated read is within alpha when both neighbours share a sign, which the sign rule already requires before the bound composes. The bound is unchanged. The two integration tests that rebuilt interpolation from nearest-rank reads now call quantile_interpolated. * fix(executor): top-k heaps keep the old kernel's membership sketchlib's heap rescores only the key an update touches, so a resident that shares a cell with it keeps a stale count, and a new key could be admitted against that stale minimum and evict a resident whose current estimate is higher. The old kernel rescored every candidate after every update. Each heap update now writes the counters, calls rescore_heap (sketchlib #245), and only then offers the updated key, so admission compares against current estimates; O(heap_size) per update, as before. rows() already reads every retained key at its current estimate, and a merge re-estimates both heaps' keys, so the readout needs no extra pass. A reference-model test (written first, failing at update 15 without the rescore) replays colliding Count-Min and Count Sketch streams and checks after every update that the retained keys match rescoring every candidate, up to exact ties. Weight checks now use sketchlib's check_weight and try_update_weighted: the counters stay finite per probed cell, as in the old kernel, instead of the Planner-side bound on the summed weight magnitude, so a merge checks the merged counters instead. * test(executor): #802's signed-sample quantiles expect PromQL's interpolated values * style(executor): rustfmt the DDSketch quantile tests
main after release 0.4.0 carries #245–#248, which the old pin took from a branch, plus #249 (`memory_bytes` / `estimated_memory_bytes` across the sketches; KMV builds without `experimental`) and #251 (the public capability table). The executor drops the `experimental` feature: KMV was the only gated type it used. #251 makes `Kll::merge` panic when k or m differ. The KLL kernel checked only k, so a state adopted with another m would reach the panic; it now checks both and returns an error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, #946, #947) (#989) * refactor(optimizer): derive Pass 1's summary candidates from the family-capability table `summary_candidates` was a hand-written intent → family list that repeated the family-capability table in the other direction (#953). It now takes the intent's statistic and keeps, in `FAMILY_PREFERENCE` order, the families whose table row answers it, less `UNIMPLEMENTED_FAMILIES` (Theta) and less UnivMon for a distinct-tuple count. Candidate lists and their order are unchanged. - The intent → statistic mapping is written once, `sketch_statistic`; Pass 1's `statistic` wraps it with its existing error. - `local_guarantee` returns `None` for a sketch read for a statistic its table row does not answer. Plain values and exact aggregates are not checked: the table lists no readouts for them. - Two estimator tests read CMS for a distinct count and Count Sketch for a total, which the table does not answer; they now read a statistic each family answers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * build: pin asap_sketchlib to main 950bb5c main after release 0.4.0 carries #245–#248, which the old pin took from a branch, plus #249 (`memory_bytes` / `estimated_memory_bytes` across the sketches; KMV builds without `experimental`) and #251 (the public capability table). The executor drops the `experimental` feature: KMV was the only gated type it used. #251 makes `Kll::merge` panic when k or m differ. The KLL kernel checked only k, so a state adopted with another m would reach the panic; it now checks both and returns an error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(types): read the sketch rows of the family-capability table from asap_sketchlib (#953) The family-capability table kept its own copy of what each sketch family answers and how it merges. A sketch family's row is now asap_sketchlib's (`SketchFamily::capability`, #251), mapped from `SketchAlgorithm` and `HydraKind` and read in the planner's vocabulary at query time: the seven statistics `StatisticKind` names, `NoGuarantee` as `Approximate`. The planner keeps the rows the library has none for: the exact accumulators and Theta. `readouts` becomes a method; its readers follow. Cells that change: - Count-Min and UnivMon totals are `Exact`, not `AsIfBuiltOverUnion`. Every consumer accepts both alike. - HydraKll merges disjoint inputs, its quantile as if built over the union: sketchlib's Hydra merge now requires the same k and m and returns an error otherwise. Nothing builds a HydraKll yet: Pass 1 offers no Hydra and the executor binds only HydraCms. - UnivMon lists its readouts in sketchlib's order, so its entry in the executor's supported summaries does too. sketchlib also lists DDSketch's total, the heap sketches' item frequency and HydraCms's total, which nothing here reads yet. `NOT_READ_YET` keeps them out, so Pass 1's candidates, the IR's estimate validation and the executor's supported summaries are unchanged; a test checks sketchlib still lists each entry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(executor): charge DDSketch and UnivMon states sketchlib's byte counts (#947) A DDSketch state was reserved at the trait's flat 4096 bytes whatever it held, and its updater counted the bins in use; a UnivMon state by a hand formula (16 bytes per counter, 256 per heap slot). Both now read asap_sketchlib's count (#249): - DDSketch: `memory_bytes()`, the bins its stores allocate; the updater charges the same. - UnivMon: its own fields, `UnivMon::estimated_memory_bytes` for its shape with keys that own no bytes, and the existing bound on the variable-length keys its heaps retain. `memory_bytes()` walks every heap, and a build charges memory after every row, so the charge stays constant time. KLL, KMV, Count-Min, Count Sketch (with or without a heap) and HLL already charge what their sketch holds plus their own fields; Hydra has no sketchlib count. They are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(plan-selection): price summary state bytes with sketchlib's estimates (#946, #947) Plan selection priced Count-Min, Count Sketch (with or without a heap), DDSketch, HLL, KMV and UnivMon states from hand-copied sketchlib internals: 24 bytes per top-k heap slot (a slot holds 150–225 plus its key), 8 per UnivMon counter, DDSketch's bucket chunks. Each is now asap_sketchlib's `estimated_memory_bytes` (#249) for the type the executor builds (`f64` counters, `Vector2D` storage), given what `sketch_shape` assumes the state holds: - Count-Min, Count Sketch, HLL: exact. - KMV: the rows the state folds as its distinct items; exact only at that many distinct inputs, an upper bound below. - DDSketch: sketchlib's worst case over insertion orders and merges, for the rows it folds over the assumed nine decades, with negative values unless proven non-negative: 37,360 bytes at α = 0.01 with rows unknown, where the old model gave 18,576. - Count-Min / Count Sketch with heap: a full heap of keys of `ASSUMED_HEAP_KEY_BYTES`, an assumption of three 16-byte labels in the executor's tuple-key encoding (63 bytes). It applies to these heaps only. - UnivMon: every layer's heap full of keys of `ASSUMED_VALUE_KEY_BYTES`, one 16-byte label value with the executor's type tag (17 bytes): Pass 1 feeds UnivMon one column, which a distinct count over a label makes Utf8. KLL keeps the planner's own formula, unchanged: sketchlib's estimate also counts a CDF cache the executor never builds. Theta keeps its `k` hashes. An α outside (0, 1), where sketchlib's DDSketch estimate panics, is priced as an empty sketch. A test builds each family the executor builds per group through its factory, feeds it rows shaped as the pricing assumes, and compares the executor's charge with the priced bytes plus each accumulator's own named fields. The heap families' shapes are dominated by their heaps. - Count-Min, Count Sketch, HLL and KMV: equal. - KLL: equal up to k rows. Past k only a loose guard holds, at most sketchlib's 2 (k - 1) items of compaction scratch over the price; the formula can price below the charge there, a known gap until a sketchlib estimate without the CDF cache can price KLL. - DDSketch, the heap sketches and UnivMon: at least the charge, and at most 1.5 times it once the state is full. UnivMon's price also bounds the bytes its sketch holds, by at most 1.6 times: its estimate includes digest-index growth under churn that the test's labels do not reach. HydraCms is left out: plan selection prices an independent Count-Min per group, the executor one shared grid. Plan selection's tests gain the executor as a dev-dependency for the test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor: read asap_sketchlib's capability vocabulary directly; one executor support declaration - SketchAlgorithm, StatisticKind and MergedError are asap_sketchlib's (exhaustive, serde); the Planner's copies, the Readouts enum, NOT_READ_YET and the conversion matches are gone. FamilyCapability's sketch rows are the library's rows, unchanged; the exact accumulators keep Planner rows. - Theta is deleted (no library sketch), with UNIMPLEMENTED_FAMILIES. - The executor declares what it evaluates in one positive list, EVALUATED_READOUTS, replacing EVALUATED_FAMILIES and UNEVALUATED_READOUTS; a test checks every entry is in the table, so a sketchlib bump cannot widen the declared support. - The four hard-coded heap-family TopK checks read answers(TopK); the pcc.rs statistic() wrapper is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: drop deleted Theta from KMV docs; name sketchlib #254 in the pin comment Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: zzylol <zeyingz@umd.edu> Co-authored-by: zzylol <50204836+zzylol@users.noreply.github.com>
Summary
Part of #239 — adds the posterior (query-time) sketch error estimator from
Chen, Wu, Yang, Jiang, Liu, "Precise Error Estimation for Sketch-based Flow
Measurement"
(IMC '21), as a pure, sketch-object-agnostic module, plus an opt-in
plan-time sizing option in
boundary.rsmotivated by the same paper.What's implemented — the estimator (new
asap_types::post_asap::error_estimation)Fetched the paper (§3, §3.2, §3.3, and Appendix A.1 — including re-rendering
the PDF pages as images to verify formulas the text extractor garbled) and
implemented, as plain functions over
&[u64]/&[i64]counter slices andnumeric params (no concrete sketch type involved — see "What's not
implemented" below for why):
cms_posterior_error_bound— §3.1 Algorithm 1 (CM-Sketch): givenone row's
wcounters,rrows, and target confidence1-δ, returns the⌊w·δ^(1/r)⌋-th largest counter as the error bound. (The paper's ownAlgorithm 1 pseudocode box prints
⌈wp⌉while its prose says⌊wp⌋— aninternal inconsistency in the source paper; this implementation follows
the prose, documented in the function's doc comment. The two conventions
differ by at most one rank, within the paper's own
O(1/√w)bias bound.)cu_sketch_posterior_error_bound— Appendix A.1 Algorithm 2(CU-Sketch): the paper states this is identical to Algorithm 1 (both
query paradigms return the min counter across rows), so this delegates to
cms_posterior_error_boundrather than duplicating logic.count_sketch_posterior_error_bound— Appendix A.1 Algorithm 3(Count-Sketch): the median-based variant. Verified by rendering the PDF
page as an image (the text extractor garbled the binomial-tail formula).
Requires odd
r— Theorem A.1 only states the optimal bound forr = 2k+1; the paper's footnote for evenr("similar equation for aneven r") doesn't spell out the formula, so per the issue's instruction to
"only implement what you can read and verify," even
rreturnsNonerather than guessing.
classic_cms_sizing/traditional_a_priori_bound— thetraditional a priori bound this issue is contrasted against (§3.3, just
before Eq. 6):
w = ⌈e/ε⌉,r = ⌈ln(1/δ)⌉, bound= ε·|F|₁. Necessarilya second implementation of the same formula as
boundary.rs's privatecms_width/cms_depth(asap-types sits below asap-aware-mapping in theworkspace's dependency layering, so it can't import them) — pinned equal
to
boundary.rs's own worked example in a test so the two can't silentlydrift apart undetected.
Correctness evidence
The real, checkable property from the paper's Eq. 6 (posterior bound is
always ≤ the traditional worst-case bound for the same
(w, r)sizing):cms_posterior_bound_is_at_most_traditional_boundbuilds synthetic CMS rows(uniform and skewed mass distributions, a fixed-seed deterministic PRNG —
no new crate dependency) across several
(ε, δ)targets sized viaclassic_cms_sizing, and asserts the posterior bound never exceedsε·|F|₁. A companion test nails the degenerate all-mass-in-one-countercase, and
kth_largest_pigeonhole_propertychecks the elementary fact thepaper's Eq. 6 proof leans on (
k-th largest ofwnon-negative valuessumming to
Tis≤ T/k) directly and unconditionally.Also: hand-computed examples, monotonicity (tighter bound as width grows
holding total mass fixed; rank grows monotonically with
r), edge cases(
δat 0/1 boundaries, all-zero counters, single row, empty row, evenrfor Count-Sketch). 19 tests, all passing in
crates/types/src/post_asap/error_estimation.rs.What I found re: sketch-runtime state (confirms the issue's premise)
Confirmed — this repo has no vendored CMS/CountSketch/CU-Sketch
implementation and no runtime counter-array data structure anywhere.
crates/types/src/post_asap/sketch.rs'sSketchKind/SketchParamsarepurely planning-time sizing metadata (width/depth/precision/k numbers, never
an actual counter matrix), and
crates/asap-aware-mapping/src/boundary.rs'sdefault_size_params/cms_width/cms_depthonly ever compute those sizing numbers. Arg/grepsweep forstruct.*Cms,impl.*Cms, and any counter-array typeacross the workspace turns up nothing beyond that metadata.
So the estimator functions above take plain counter slices rather than a
concrete sketch type, specifically so a future real sketch runtime's readout
path can call them directly on its real counter arrays with zero changes
needed here — see the module's doc comment for this stated explicitly.
What's wired for real — plan-time sizing (
boundary.rs::posterior_aware_size_params)New, purely additive function alongside (never called from, never changing)
default_size_params: given a caller-suppliedExpectedCaseSizing { width_relaxation }(a fraction in(0,1)of the worst-casecms_widththecaller is betting is enough for a non-adversarial workload), it sizes a
smaller CMS-family sketch (
Cms/CmsWithHeap/CountSketch/CountSketchWithHeap) — depth is left at the worst-case formula (relaxingdepth trades away confidence exponentially, a materially different and
riskier axis, out of scope here). The doc comment spells out exactly what's
being traded: the
(ε,δ)guarantee becomes conditional on the statedassumption holding, not unconditional — this function cannot and does not
verify the assumption, since no data exists at plan time.
This is not a literal call into the posterior estimator (there's no real
counter data at plan time to feed it) — it's this crate's own plan-time
analogue of the same "expected-case workloads need less" intuition, clearly
documented as such rather than overclaiming a direct connection to the
paper's technique.
Tests:
width_relaxation < 1.0produces a strictly smaller width thandefault_size_params;width_relaxation == 1.0(or any out-of-range value:0.0, negative,>1.0,NaN,∞) reproducesdefault_size_paramsexactly; depth is unaffected; every CMS-family
SketchKindis covered;every other
SketchKindis byte-for-byte identical todefault_size_params;and a regression pin confirms
default_size_params's own existing behavioris unchanged.
What's deferred, and why
attempted — there is no real sketch runtime to wire into (see above). The
estimator functions are ready to be called the moment one lands; that
wiring is explicit follow-up scope for Add posterior (query-time) error estimation for CMS/CountSketch, per Chen et al. IMC'21 #239.
posterior_aware_size_params: explicitly out ofscope for this PR (see that function's doc comment) — a different,
exponential-confidence tradeoff from width's linear one.
CU-Sketch and Count-Sketch algorithms were verified by rendering the PDF
pages as images after the text extractor garbled the formulas. The only
narrowing is Count-Sketch's even-
rcase, deferred because the paperitself doesn't spell out that formula (see
count_sketch_posterior_error_bound'sdoc comment).
Process
cargo build --workspace --all-targets— cleancargo test --workspace— all passing (19 new tests inerror_estimation.rs, 6 new tests inboundary.rs, no existing testchanged)
cargo fmt --all -- --check— cleancargo clippy --workspace --all-targets --all-features -- -D warnings—clean
🤖 Generated with Claude Code