A frequency sketch, fused

How often has this key been happening lately — in one 64-bit word, one CAS, forever.

Count, recency, and first-sighting were always three separate structures, kept painfully in sync. EpochSketch fuses them into a single packed word — one cache line, one atomic compare-and-swap per decision. Decay is never performed by anyone; it's interpreted at read time, so there is no background mutator at all.

Read the design →View on GitHub
Go 1.22+MIT License0 allocs/opRace-detector clean
Typical output — five observations, one new key
sk := epochsketch.New(epochsketch.Config{
  NumBuckets:   1 << 16,
  TickDuration: time.Second,
})

est, first := sk.Observe("checkout:sku-42")
observe #1 -> estimate=1 first=true
observe #2 -> estimate=2 first=false
observe #3 -> estimate=3 first=false
observe #4 -> estimate=4 first=false
observe #5 -> estimate=5 first=false
new key     -> estimate=1 first=true
#01

What it guarantees, in numbers

ConstraintWhat it meansEvidence
Fixed memoryAllocated once at construction, independent of key cardinalityNumBuckets × 8 words, never resized
Nanosecond-scaleCost comparable to a memory access; zero heap allocation per Observe~21 ns/op single-threaded, 0 allocs/op
Intrinsic decayRecency is a read-time interpretation — no cleanup sweep, no reset pausecount >> age
Concurrent by constructionMany goroutines call Observe at once; no locksLock-free CAS, race-detector-clean
Safe error directionWhen forced to approximate, underestimates the small, never loses the largeFail-open: a lost eviction resolves to "first," never overcounts
#02

One word, three questions

6347230TAGEPOCHCOUNT16 bits24 bits24 bitsa fingerprint —which keythe write epoch —when it happeneda saturating count —how many timesone uint64 — one cache line, one atomic CAS
Every slot is a single 64-bit word, not three. The tag answers "which key," the epoch answers "when," and the count answers "how many" — read or written together in one atomic operation, never three structures that can drift out of sync with each other.
#03

Decay is read, not run

STORED (unchanged)READ RIGHT NOWnot written to in betweenSLOT A · age = 1 tickcount200count >> 1estimate100SLOT B · age = 6 tickscount200count >> 6estimate3
Same stored bits, two different reads. Nothing wrote to either slot between the two observations — the estimate falls from 100 to 3 purely because age grew from 1 tick to 6 and the shift is computed fresh on every read. No sweep, no reset, no background timer anywhere in the structure.
#04

Why not just use ___

If you reach forIt answersWhat's different here
map[string]intExact counts, small keyspaceGrows without bound; EpochSketch trades exactness for fixed memory at any cardinality
HyperLogLogHow many distinct keysA different question entirely — cardinality, not per-key frequency
Count-Min SketchHow often, everNo decay — a key hot last week looks as hot as one hot right now, until a full sweep
Caffeine / RistrettoCache admission decisionsDecay via periodic bulk halving, bundled inside a full cache — this is just the signal