Skip to content

Fix three bugs in Nitro geometric row-sampling - #82

Open
zzylol wants to merge 3 commits into
mainfrom
fix/nitro-geometric-sampling-bugs
Open

Fix three bugs in Nitro geometric row-sampling#82
zzylol wants to merge 3 commits into
mainfrom
fix/nitro-geometric-sampling-bugs

Conversation

@zzylol

@zzylol zzylol commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes three independent bugs in Nitro (row-level geometric sampling), found while implementing a downstream demo that inserts via fast_insert_nitro / NitroBatch::insert and reads back with nitro_estimate/estimate.

  1. CountMin::fast_insert_nitro underflow at rate == 1.0. Unlike Count::fast_insert_nitro, it was missing the loop that lets a single call touch multiple rows. At full sampling r=0, temp=0 always, so (r + temp + 1) - rows underflows for rows > 1, permanently disabling further row-touches for the sketch's lifetime.
  2. Nitro::draw_geometric (structure_utils.rs) ignored the configured sampling_rate. Its real RNG-based draw was commented out (// for profiling) and replaced by a fixed ~1%-rate precomputed table lookup, so every rate other than ~0.01 was silently miscalibrated.
  3. Off-by-one in the row-cursor step, in both Nitro implementations. draw_geometric generated Geo(p) directly (support {1,2,...}, mean 1/p), but the callers (fast_insert_nitro, NitroBatch::insert) advance the cursor via r += to_skip + 1. Per NitroSketch paper Algorithm 1 (r += Geo(p), r initialized at -1), that +1 is already the "touch" step and should not be added on top of a full Geo(p) draw. The double-counted +1 inflated the true step mean to 1/p + 1, so the achieved sampling density was p/(1+p) instead of p (e.g. ~33% actual density at a configured rate of 50%), systematically undercounting every Nitro estimate. Fixed by drawing Geo(p) - 1 instead, so the existing +1 at call sites is correct.

Verification

  • Isolated probe: inserted a single key 1,000,000 times via fast_insert_nitro at rates [1.0, 0.5, 0.1, 0.05, 0.02, 0.01] for both CountMin and Count. Before the fix, estimates were either flat/rate-independent or off by up to ~33%. After, estimates track the true count within ~1-2.5% at every rate.
  • cargo test: full suite (497 unit tests + integration tests + doctests) passes, including the Nitro-specific accuracy tests (nitro_batch_countmin_error_bound_zipf, nitro_batch_count_error_bound_zipf) -- no regressions.

Test plan

  • cargo build
  • cargo test (497 passed, 0 failed)
  • Standalone probe confirming estimate accuracy at multiple sampling rates for both CountMin and Count

1. CountMin::fast_insert_nitro was missing the loop present in
   Count::fast_insert_nitro, causing a usize underflow in
   `(r + temp + 1) - rows` at full sampling (rate == 1.0), which
   silently disabled all further row-touches for the sketch's lifetime.

2. Nitro::draw_geometric (structure_utils.rs) had its live RNG-based
   draw commented out and replaced by a fixed ~1%-rate precomputed
   table lookup, ignoring the configured sampling_rate entirely at
   every other rate.

3. draw_geometric (both structure_utils.rs::Nitro and
   sketch_framework/nitro.rs::NitroBatch) generated Geo(p) directly
   (support {1,2,...}, mean 1/p), but callers advance the row cursor
   via `r += to_skip + 1`, which double-counts the +1 already implied
   by the paper's Algorithm 1 (`r += Geo(p)`, r initialized at -1).
   This inflated the true step mean to 1/p + 1, so the achieved
   sampling density was p/(1+p) instead of p -- e.g. ~33% actual
   density at a configured rate of 50%, systematically undercounting
   every Nitro estimate. Fixed by drawing Geo(p) - 1 instead.

Verified via an isolated probe inserting a single key 1M times at
rates [1.0, 0.5, 0.1, 0.05, 0.02, 0.01] for both CountMin and Count:
estimates now track the true count within ~1-2.5% at every rate,
versus wildly biased or rate-independent results before. Full test
suite (497 unit + integration + doctests) passes with no regressions.
@zzylol
zzylol requested a review from GordonYuanyc August 4, 2026 18:14
zzylol added 2 commits August 4, 2026 12:23
nitro_estimate (CountMin) queried via hash_for_matrix, which for some
(rows, cols) dimensions selects a Packed64 hash layout hashed with
hash64_seeded -- a different algorithm than the raw hash128_seeded(0,
value) that fast_insert_nitro writes with. Insert and query only
happened to agree at dimensions landing in Packed128 mode (where the
seed-0 case coincides with hash128_seeded(0, key)), masking the bug at
the row/col configuration used by the earlier verification probe.
Fixed by adding Vector2D::query_by_row (mirrors update_by_row's exact
column extraction) and rewriting nitro_estimate to hash and read the
same way fast_insert_nitro writes, for both CountMin and Count
(Count's fast_insert_nitro previously had no matching query method at
all -- callers had to fall back to the also-hash_for_matrix-based
regular estimate()).

Also:
- Added fast_insert_nitro_many(value, many) to both CountMin and Count
  (Vector2D<i32> and Vector2D<i64>), compensating by
  Nitro::scaled_increment(many) instead of the fixed per-occurrence
  delta -- lets one row-cycled, geometrically-sampled call stand in
  for a weighted observation (e.g. a metric sample's value) without
  looping the insert call itself, keeping cost proportional to event
  count rather than magnitude.
- Mirrored all Nitro support (enable_nitro, fast_insert_nitro[_many],
  nitro_estimate) for Vector2D<i64> storage, for callers whose summed
  weights can exceed i32::MAX.

Verified via the same isolated probe, extended with a
rows=3/cols=8 case (forces Packed64 mode, previously broken) and a
weighted-insert case for both i32 and i64 storage on CountMin and
Count -- all track the true value within sampling noise. Full test
suite (497 unit + integration + doctests) still passes.
Nitro::init_nitro (and everything built on it: Vector2D::enable_nitro,
CountMin/Count::enable_nitro) always seeds its geometric-skip RNG from
OS entropy (new_small_rng()), with no way to inject a caller-supplied
seed -- appropriate for production use, but it means a caller that
needs deterministic, reproducible runs (e.g. a benchmark harness
driven by its own --seed flag) can't get repeatable Nitro sampling
decisions at all.

Added Nitro::init_nitro_seeded(rate, seed), Vector2D::enable_nitro_seeded,
and CountMin/Count::enable_nitro_seeded (both Vector2D<i32> and
Vector2D<i64>) as siblings to the existing unseeded versions, seeding
via SmallRng::seed_from_u64(seed) instead.
@zaoxing

zaoxing commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

@zzylol did you run some comprehensive accuracy tests before vs after?

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.

2 participants