Skip to content

fix(precompute): restore HLL delta-vs-full invariant under sparse representation - #474

Merged
zzylol merged 1 commit into
mainfrom
fix/hll-sparse-delta-invariant
Jun 1, 2026
Merged

zzylol merged 1 commit into
mainfrom
fix/hll-sparse-delta-invariant

Conversation

@zzylol

@zzylol zzylol commented May 31, 2026

Copy link
Copy Markdown
Contributor

Summary

TestHLLWrapper_DeltaNeverLargerThanFull (in asap-precompute-go/sketches/) began failing on a pristine origin/main tree after the sketchlib-go dependency was bumped to the merged in-memory sparse HLL commit (fff038b, sketchlib-go PR #66, "in-memory sparse representation with dense promotion"). Consumed via the local replace => ../../sketchlib-go directive.

Verdict: (B) — the test's assumption is stale. This is a test-only fix. The production delta/serialization path is correct: the min(full, delta) clamp in HLLWrapper.ComputeDeltaAgainst is intact and the real bandwidth invariant holds in every regime.

Evidence (byte numbers across cardinalities)

The failure is not the size invariant (len(payload) > len(full)) — that never triggers. It is the second sub-case's stale expectation that near-empty-base (base=1, +400) must clamp to a FULL frame (isFull == true).

Measured HLLWrapper.ComputeDeltaAgainst payload vs the actual same-state full snapshot:

case base extra same-state full delta payload isFull delta > full?
large-base 20000 +50 16532 88 false no
near-empty-base 1 +400 957 805 false no
from0 +1 0 1 153 5 false no
from0 +100 0 100 398 248 false no
from0 +4096 0 4096 7445 7295 false no
from0 +6000 0 6000 10249 10099 false no
from0 +50000 0 50000 16532 16532 true no

Before #66 the "full" frame for a low-cardinality sketch was effectively the dense ~16KB register array, and for the base=1, +400 case the register delta exceeded the serialized full frame, so the clamp fired (isFull=true). After #66 the full snapshot is representation-adaptive: that same state now serializes to a 957-byte wire-sparse frame, while the register delta is 805 bytes — genuinely smaller. So ComputeDeltaAgainst correctly keeps the delta (isFull=false) instead of clamping. The test's hardcoded expectation that this specific input forces a full is what broke; the production invariant did not.

The genuine invariant — "the delta path never ships more bytes than the same-state full snapshot" — holds everywhere (delta > full? is always no). The clamp still fires correctly at high cardinality (0, +50000: the delta reaches the dense full size and emits full).

Fix

Rewrote TestHLLWrapper_DeltaNeverLargerThanFull (test only) to:

  • assert the size invariant directly against the ACTUAL same-state full snapshot (never a hardcoded dense constant), so it stays correct whether the full frame is sparse or dense;
  • pin BOTH clamp regimes without assuming which fixed input lands in which: a small/medium increment must keep a strictly-smaller delta, and a high-cardinality-from-empty delta must grow until it reaches the dense full size and clamp to full.

The original intent (the delta path is never a pessimization vs sending full) is preserved, not loosened.

  • Files changed: asap-precompute-go/sketches/hll_test.go (test only; no production code).

Results

  • go test ./sketches/ -run HLL — PASS (all 9 HLL tests)
  • go test ./sketches/ — ok
  • go vet ./sketches/ — clean
  • gofmt -l sketches/hll_test.go — clean

Provenance / stacking

🤖 Generated with Claude Code

…resentation

The sketchlib-go #66 merge (in-memory sparse HLL with dense promotion)
changed the full HLL wire snapshot to be representation-adaptive: a low/
medium-cardinality sketch now serializes to a small wire-sparse frame, and
only a high-cardinality sketch reaches the dense register array (~16.5KB).

TestHLLWrapper_DeltaNeverLargerThanFull's second sub-case asserted that a
near-empty base + medium increment (base=1, +400) must clamp to a FULL frame
(isFull==true). That assumption is now stale: the same-state full snapshot is
itself sparse (957B) and the register delta (805B) is genuinely SMALLER, so
ComputeDeltaAgainst correctly KEEPS the delta. The production min(full,delta)
clamp is intact — the real invariant "payload never larger than the same-state
full snapshot" holds in every regime (verified: 20000+50 -> 88<=16532;
1+400 -> 805<=957; 0+50000 -> 16532==16532, clamp fires).

This is a stale-test fix (B), not a production bug. The test now asserts the
size invariant directly against the ACTUAL same-state full snapshot (never a
hardcoded dense constant), and exercises BOTH clamp regimes: a small/medium
increment keeps a strictly-smaller delta, and a high-cardinality-from-empty
delta grows until it reaches the dense full size and clamps to full.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@zzylol
zzylol force-pushed the fix/hll-sparse-delta-invariant branch from 115ec23 to 28627ce Compare May 31, 2026 18:17
@zzylol
zzylol marked this pull request as ready for review June 1, 2026 13:21
@zzylol
zzylol merged commit fb4ae0c into main Jun 1, 2026
@zzylol
zzylol deleted the fix/hll-sparse-delta-invariant branch June 1, 2026 13:22
zzylol added a commit that referenced this pull request Jun 1, 2026
…ssor

Complete the processor side of the opt-in sparse HLL feature (engine side
landed in the preceding commit). Lives in the contrib-patch overlay
(opentelemetry-collector-contrib-patch/processor/asapedgeprocessor), the
source of truth committed in this private repo; restore_otel_collector_contrib_patches.sh
stages it onto the upstream-pinned submodule for build/test.

- config.go: add `HLLSparse bool` (mapstructure "hll_sparse") to MetricFamily.
  Default false (dense). Opt-in selects the sparse in-memory HLL base for an
  HLL family so low-cardinality warm series avoid the dense ~16KB/series
  register array; serialized output is byte-identical to dense (pure in-memory
  footprint win, no wire change). Only consulted for family=hll.
- config_validate.go: reject hll_sparse on any non-HLL family at boot
  (mirrors the emit_heap family guard) so a misconfiguration surfaces early.
- warm_sketch.go: in the FamilyHLL branch, build sketches.NewHLLWrapperSparse()
  when fam.HLLSparse, else the dense NewHLLWrapper(). The constructor is the
  source of truth for base selection; the choice is also surfaced as the
  documented HLL "sparse" SketchParams key (1=sparse, absent=dense) on the
  emitted PrecomputeConfig for introspection.
- hll_sparse_test.go: assert default => dense (SketchParams[sparse] absent),
  hll_sparse=true => SketchParams[sparse]=1, both factories build a usable
  *sketches.HLLWrapper, plus config round-trip + the non-HLL family guard.

Remove the now-obsolete asapedge-hll-sparse-wiring/README.md placeholder
(it captured the pending fragments; the wiring is now committed).

Rebased onto merged #471 (dual-mode aggregation) and #474 (HLL delta
invariant). The sketchlib-go NewSparseHyperLogLog dependency is merged to
main and resolves via the existing local replace; no pin.

Build/test: asap-precompute-go `go test ./... && go vet ./...` pass; the
asapedgeprocessor module builds, vets, gofmts clean, and `go test ./...`
passes (incl. the new sparse tests and #471's whole_stream tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
zzylol added a commit that referenced this pull request Jun 1, 2026
)

* feat(precompute): opt-in sparse in-memory HLL for warm aggregators

Add an opt-in sparse base for the HLL precompute wrapper so low-cardinality
warm series no longer pay the dense ~16KB/series register-array cost.

- sketches/hll.go: add NewHLLWrapperSparse(), backed by
  hll.NewSparseHyperLogLog() (sketchlib-go #66). A new `sparse` field on
  HLLWrapper is threaded through the shared newSketch() helper so Reset / Merge
  / ApplyDelta rebuild a sparse base and a sparse wrapper never reverts to the
  dense footprint. The wrapper drives the inner sketch only through its public
  methods and never touches the exported Registers field. Snapshot / Merge /
  ApplyDelta / Reset / EstimateCardinality are unchanged and remain
  byte-identical / interoperable with the dense base.
- config.go: document the recognized HLL "sparse" SketchParams key.
- sketches/hll_sparse_test.go: estimate parity, byte-identical snapshots,
  dense<->sparse merge interop, Reset, and a low-cardinality heap check.

Dependency: sketchlib-go #66 is MERGED to main (fff038b); resolved via the
existing `replace => ../../sketchlib-go` directive, so no go.mod change.

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

* feat(asap_edge): wire opt-in sparse in-memory HLL into the edge processor

Complete the processor side of the opt-in sparse HLL feature (engine side
landed in the preceding commit). Lives in the contrib-patch overlay
(opentelemetry-collector-contrib-patch/processor/asapedgeprocessor), the
source of truth committed in this private repo; restore_otel_collector_contrib_patches.sh
stages it onto the upstream-pinned submodule for build/test.

- config.go: add `HLLSparse bool` (mapstructure "hll_sparse") to MetricFamily.
  Default false (dense). Opt-in selects the sparse in-memory HLL base for an
  HLL family so low-cardinality warm series avoid the dense ~16KB/series
  register array; serialized output is byte-identical to dense (pure in-memory
  footprint win, no wire change). Only consulted for family=hll.
- config_validate.go: reject hll_sparse on any non-HLL family at boot
  (mirrors the emit_heap family guard) so a misconfiguration surfaces early.
- warm_sketch.go: in the FamilyHLL branch, build sketches.NewHLLWrapperSparse()
  when fam.HLLSparse, else the dense NewHLLWrapper(). The constructor is the
  source of truth for base selection; the choice is also surfaced as the
  documented HLL "sparse" SketchParams key (1=sparse, absent=dense) on the
  emitted PrecomputeConfig for introspection.
- hll_sparse_test.go: assert default => dense (SketchParams[sparse] absent),
  hll_sparse=true => SketchParams[sparse]=1, both factories build a usable
  *sketches.HLLWrapper, plus config round-trip + the non-HLL family guard.

Remove the now-obsolete asapedge-hll-sparse-wiring/README.md placeholder
(it captured the pending fragments; the wiring is now committed).

Rebased onto merged #471 (dual-mode aggregation) and #474 (HLL delta
invariant). The sketchlib-go NewSparseHyperLogLog dependency is merged to
main and resolves via the existing local replace; no pin.

Build/test: asap-precompute-go `go test ./... && go vet ./...` pass; the
asapedgeprocessor module builds, vets, gofmts clean, and `go test ./...`
passes (incl. the new sparse tests and #471's whole_stream tests).

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

---------

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