Skip to content

Sketch and monoid parameter safety (wire format deliberately unchanged) - #92

Merged
pequalsnp merged 3 commits into
mainfrom
fix/sketch-parameter-safety
Aug 28, 2026
Merged

Sketch and monoid parameter safety (wire format deliberately unchanged)#92
pequalsnp merged 3 commits into
mainfrom
fix/sketch-parameter-safety

Conversation

@pequalsnp

Copy link
Copy Markdown
Contributor

Verified defects in pkg/monoid/sketch and pkg/monoid/compose. Review returned needs-work and its most important finding removed a feature from this PR entirely.

What review stopped

The first version added a TopK header field carrying total ingested weight, to make saturation detectable. Forward compatibility was handled — old rows have the flag clear and new code reads them. Backward was not, and a rolling deploy needs both.

Verified against the live soak's format:

main's Items(newRow):           items=0  err=keyLen[1] claims 808286580 bytes but only 244 remain
main's Combine(newRow, delta):  items=1  total=1
   ...the row genuinely held 40 events across 12 entities

An old task silently discards the accumulated sketch and CAS-writes a 1-event row back over it. During a rolling deploy with mixed tasks, that destroys accumulated state — on the pipeline currently under a 90-day soak whose entire value is accumulation.

(Note the bound check from #77 is what turns corruption into discard. Better, still silent.)

The wire-format change is out. Re-verified after removal: main reads branch-written rows exactly — 12 items, 40 events, Combine yields 41. It returns later as a properly staged two-phase change: read-both/write-old, then flip.

What ships

Bloom shape mismatches are reported. Merging sketches with different (m, k) shapes silently returned the left operand and merged to a permanent Identity — with the WithDecodeErrorHandler hook firing zero times, though its own doc claimed to cover exactly this. A bitwise OR of two differently-sized bit arrays has no definition, so reporting is the only honest option.

One future-dated event no longer freezes a DecayedSum key. After a single event 4 years ahead, every later event becomes the older operand and its Exp2 factor underflows to exactly 0 — the accumulator sticks at 1.0 forever, and the read path evaluates it to +Inf, pinning that key to rank #1 permanently. Fixed in Combine, not just EvaluateAt: a 1-year skew at halfLife=24h yields a finite 7.3e109 that still outranks every real score forever.

halfLife <= 0 is rejected. It was accepted, after which Combine and EvaluateAt implemented opposite semantics for the same confighl=0, dt=0 produced NaN, which survives Encode/Decode and poisons the row permanently. Reachable from murmur.Trending(name, cfg.HalfLife) with an unset Duration.

compose gained a decode-error handler, which hll/topk/bloom had and it lacked. DecodeDecayed on a foreign 200-byte HLL blob previously decoded to Set=true garbage.

The TopK saturation tests stay even without the header. They document the cliff on today's API: at K=32, 32 distinct entities returns 32 items summing to the full stream, while 33 returns 29 items summing to 29. Worth having, given the soak's shipped K=32 over a single "global" key — its 12-hour run sat at 30 distinct entities, one below the edge, so it produced zero evidence about saturated behaviour.

Two more review findings, both fixed

Combine had started calling time.Now(), making it impure. BytesStore.MergeUpdate recomputes Combine on every CAS retry, so two attempts on identical inputs could differ — and associativity is fuzzed in CI. The skew bound now comes from the operands, not the wall clock.

Six tests did not fail pre-fix, not four as reported. TestDecayedSum_SkewClampIsOptOut passed both ways: without a clamp Combine always adopts the newer T, which is exactly what it asserted. Every kept test is now verified by reversion.

Test plan

  • 20 tests across bloom, topk, compose, plus non-default-capacity cases in monoidlaws
  • Each verified to fail pre-fix by reversion
  • Rolling-deploy compatibility re-verified by hand after removing the wire-format change
  • make test-unit, golangci-lint 0 issues
  • CI green

Deferred: TopK saturation remains byte-undetectable until the staged wire-format change lands.

🤖 Generated with Claude Code

https://claude.ai/code/session_01X2YMxeLgyRc9i5XPyEV75S

@pequalsnp
pequalsnp force-pushed the fix/sketch-parameter-safety branch from ebc30cf to ae35ffd Compare August 28, 2026 14:56
Kyle Galloway and others added 3 commits August 28, 2026 12:03
Four ways a monoid's own configuration could destroy the state it was
merging into, none of which surfaced anywhere.

bloom: (m, k) is written into every marshaled filter and read back out of
it by UnmarshalBinary, so the monoid's parameters survive only until the
decode. A shape mismatch therefore decoded cleanly, fell through to the
"return the left operand" path, and reported nothing — the case
WithDecodeErrorHandler's own doc claimed to cover. Worse, Identity() was a
marshaled empty filter, so it was an identity only for filters of its own
shape: NewWithCapacity on the aggregate plus the default-sized Single in
the value extractor merged every event into a mismatch and the row stayed
empty for good. Identity is now the empty slice, which Combine already
short-circuits, and a mismatch between two real filters names both shapes
through the handler.

topk: the wire K was decoded and discarded, so a K=10 client reading a
K=32 row merged at 10 and wrote the truncation back — 22 counters gone,
unrecoverable. Combine now merges at the widest K of the monoid and both
operands (max is associative, so merge order still can't change the
result) and reports the mismatch. A wire K is untrusted, so the K adopted
from an operand is capped; without a ceiling a corrupt header switches off
eviction and the row grows until the store refuses the write.

topk saturation was undetectable: a summary holding 0.1% of its stream is
byte-identical in shape to an exact one — 32 counters summing to 45,932
with 32 distinct entities, 29 summing to 29 with 33. The header now
carries the total ingested weight as a plain associative uint64 sum, and
Inspect returns Coverage / Saturated / MaxError from it. Discarded mass is
deliberately not accumulated: it is merge-order-dependent and would break
the associativity monoidlaws fuzzes. Pre-weight rows still decode, with
PartialWeight set so a short denominator can't read as full coverage.

compose: one future-dated event ended a DecayedSum key's life. Combine
adopts the newer timestamp as the frame, so a four-year skew made the
frame unreachable — 2^(-4y/24h) underflows to exactly zero, the
accumulated mass was annihilated, and every event that followed was itself
the older operand and was annihilated in turn. The key froze at whatever
the skewed event carried while the read path scaled it up by 2^(+gap/hl):
+Inf, or 7.5e109 for a one-year skew at halfLife=24h, pinning it to rank
#1 forever. Combine now bounds the frame against the wall clock
(WithClockSkewBound, two half-lives by default), and EvaluateAt before the
reference time returns the stored value instead of un-decaying it. A
pairwise horizon can't do this job: from inside Combine, a state four
years in the future is indistinguishable from a key that went quiet four
years ago, and clamping the second pins idle keys at a frame they can
never leave.

compose also accepted halfLife <= 0, after which Combine and EvaluateAt
meant opposite things for the same row: 2^(-dt/0) is NaN at dt=0 (which
round-trips through Encode/Decode and poisons the row forever) and 0 at
dt>0 (which drops every older contribution), while EvaluateAt reported the
value undecayed. Both now mean "no decay". Reachable by accident via
murmur.Trending(name, cfg.HalfLife) with an unset Duration field.

And DecayedSumBytes had no decode-error hook, unlike hll/topk/bloom. The
wire form is a bare 17 bytes, so a 200-byte HLL blob decoded to a Set=true
observation assembled from its first 17 bytes and merged in as real.
DecodeDecayed now returns an error for any length but 0 and 17, and
DecayedSumBytes takes WithDecodeErrorHandler.

Breaking: the topk wire format gains a header field (old rows read fine,
new rows do not read on old binaries); bloom's Identity is now empty;
DecodeDecayed returns (Decayed, error); EvaluateAt no longer scales up
before the reference time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2YMxeLgyRc9i5XPyEV75S
Addresses the blocking review on fix/sketch-parameter-safety.

Removes the TopK wire-format change entirely. topk.go and topk_test.go are
now byte-identical to main: no header flags, no uint64 ingested-weight field,
no flagWeight/flagPartialWeight/kMask/maxAdoptedK, no effectiveK adoption, no
Inspect/Summary. main's Combine reading a branch-written row returned a
1-item sketch where the row held 40 events over 12 entities and then CAS-wrote
that back, so a rolling deploy with mixed tasks would have destroyed
accumulated state on the pipeline currently under soak. Forward compatibility
was handled; backward was not, and a rolling deploy needs both. It returns
later as a staged read-both/write-old change.

The saturation tests survive the revert in pkg/monoid/sketch/topk/
saturation_test.go, rewritten against today's API: they assert that one extra
entity takes a K=32 summary from 32 counters covering 45,932 events to 29
counters covering 29, and that the heavy hitter still lands inside n/(K+1).
They document the cliff; they do not need the header to do it.

decayed Combine is a pure function of its operands again. It read time.Now()
to bound clock skew, and BytesStore.MergeUpdate recomputes Combine on every
CAS retry, so two attempts on identical inputs produced different results —
and the monoidlaws fuzzer evaluates the two associativity groupings at
different instants. The skew bound cannot be derived from the operands: from
inside Combine, a state four years in the future beside a real event is
indistinguishable from a real event four years after a key went quiet, so any
pairwise clamp safe for the first case hands an idle key back a quarter of its
stale mass (the "idle key" row in TestDecayedSum_CombineAdoptsTheNewerFrame-
Exactly is that counterexample). The bound moves to the lift instead, as
compose.ClampFuture(t, now, bound) + compose.DefaultSkewBound(halfLife), which
runs once per observation rather than once per merge attempt and is the last
point where a clock reading is honest. EvaluateAt stays the read-side backstop.
WithClock and WithClockSkewBound are gone.

bloom's NewWithCapacity(n, p) parameters are no longer cosmetic. Identity
ignores them by design and UnmarshalBinary overwrites the constructed shape,
so the only thing they could honestly do is declare the shape the pipeline's
lifts must produce — Combine now reports operands that agree with each other
but not with that declaration, which is how a NewWithCapacity(1_000, 0.01)
monoid could aggregate DefaultCapacity filters forever at an FPR nothing in
the configuration predicted. Combine also stops allocating two throwaway
120 KB bit arrays per merge.

Every remaining test was checked by reverting its fix and confirming it fails.
Four that did not are dealt with: TestDecayedSum_SkewClampIsOptOut deleted
(it asserted main's own behaviour); TestDecayedSum_IdleKeyStillDecaysNormally
folded into the frame table test, which fails pre-fix; TestDecodeDecayed_
EmptyIsIdentity folded into TestDecodeDecayed_LengthContract; TestDecayedSum-
Bytes_ForeignBlobWithoutHandlerStillRecovers given a non-zero fixture, since
an all-zero blob decoded to Set=false and was discarded by the identity
short-circuit either way. TestBloom_ShapeMismatchWithoutHandlerStillRecovers
deleted — it cannot fail pre-fix, and monoidlaws already drives every
reportDecodeError call site with a nil handler.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2YMxeLgyRc9i5XPyEV75S
Kept out of the implementing commits so parallel branches did not all
conflict on the [Unreleased] heading.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2YMxeLgyRc9i5XPyEV75S
@pequalsnp
pequalsnp force-pushed the fix/sketch-parameter-safety branch from ae35ffd to 8d3c7aa Compare August 28, 2026 15:03
@pequalsnp
pequalsnp merged commit 88a5b56 into main Aug 28, 2026
6 checks passed
@pequalsnp
pequalsnp deleted the fix/sketch-parameter-safety branch August 28, 2026 15:09
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