Raw capture — raw/grudge.md


url: https://github.com/satmihir/grudge title: grudge — A constant-memory sketch that holds grudges and forgives author: Mihir Sathe (satmihir) date_fetched: 2026-07-11 date_published: 2025


grudge

A constant-memory decaying-score sketch for Go: maps an unbounded key space to a scalar score that updates with feedback and decays toward zero on its own. It is to behavioral scores what a count-min sketch is to counts. Extracted from the FAIR project (Stochastic Fair BLUE-based fairness throttling).

Architecture

The project is a single Go package (~1,860 lines of source) with one internal subpackage and one exported test-helper package:

grudge/                 — package grudge: Sketch, Rotator, Decay, Aggregator, Config, tuning
  internal/hash/        — Hasher interface, Murmur3 + SipHash implementations
  grudgetest/           — FakeClock, FakeTicker for deterministic testing

Core types

Key files

File Lines Role
sketch.go 263 Sketch struct, New, Update, Query, UpdateAndQuery, TryUpdate, QueryDetailed
decay.go 114 Decay interface, Exponential, Linear, None, validation
aggregator.go 40 Min, Max, Mean aggregators
config.go 59 Config struct with validation
rotator.go 168 Rotator: multi-generation management with background goroutine
tune.go 56 SuggestLevels: sizing from collision-probability formula
hash.go 42 Hasher/HasherFactory interfaces, Murmur3/SipHash factories
clock.go 28 Clock/Ticker interfaces with real implementations
internal/hash/hash.go 27 Murmur3 wrapper, Splitmix64
internal/hash/siphash.go 89 Full SipHash-2-4 implementation
grudgetest/clock.go 57 FakeClock, FakeTicker for deterministic tests

Dependencies

Three defining properties

  1. Collision shielding: With Min aggregation, a key's estimate is exact whenever at least one of its L cells is free of colliding traffic. Error is one-sided (Query(k) ≥ true score). False-positive probability: (1 - (1 - 1/M)^H)^L.

  2. Lazy decay: Scores fade toward zero, computed on access only. An idle sketch does zero background work. Decay must compose across time subdivision: Apply(Apply(s, t1), t2) == Apply(s, t1+t2).

  3. Rotation: A Rotator keeps N generations under different seeds, reads from the oldest, writes to all (so successors are warm), and periodically retires the oldest. Worst-case false positive lasts at most Generations × Period.

Operations

Op Description Locking
Update(k, δ) Add δ to all L cells (clamped) One cell at a time, level order
Query(k) Decay cells, return aggregate One cell at a time
UpdateAndQuery(k, δ) Update + return post-update aggregate One cell at a time
TryUpdate(k, δ, limit) Apply only if aggregate + δ ≤ limit ALL L locks held simultaneously
QueryDetailed(k) Per-level scores + cell indexes One cell at a time

Key implementation details

Testing approach

The test suite is rigorous and spec-driven:

  1. Reference-model test (TestReferenceModel): Random op sequences on a single key against a closed-form scalar reference, with a fake clock. Uses pgregory.net/rapid for property-based testing.
  2. Decay composition (TestDecayComposition): Verifies Apply(Apply(s,t1),t2) == Apply(s,t1+t2) across all three built-in decays.
  3. TryUpdate atomicity (TestTryUpdateAtomicity): 50 goroutines racing TryUpdate(k, +1, 1000) — exactly 1000 admitted, the property Query+Update can't guarantee.
  4. Determinism (TestSketchDeterminism): Same seed + clock + ops = bit-identical output.
  5. Allocation enforcement (TestHotPathAllocations): Update/Query/UpdateAndQuery must show 0 allocs/op.
  6. Goroutine leak detection: goleak.VerifyTestMain in TestMain catches any Rotator that forgets to Close.
  7. Rotation tests: Promotion order, warm-start (dual-write), goroutine wiring, Close idempotence.

Four readings of the same mechanism

The same cell structure can be used for different purposes by varying the update pattern and decay:

  1. Decayed counter: +1 per event → per-key rate estimation (Query × λ ≈ rate)
  2. Saturating latch: +1, clamp Hi=1 → a Bloom filter that forgets (streaming dedup, novelty detection)
  3. Feedback score: ±δ on outcomes → FAIR-style fairness, abuse scores, circuit breakers
  4. Debt meter: +cost, Linear decay → token-bucket rate limiting over unbounded keys

Design decisions

Non-goals (v1)

Milestones implemented

  1. M1 — Core: cell, lazy decay, 3 Decays, 3 Aggregators, murmur3+KM hashing, Update/Query/UpdateAndQuery/QueryDetailed
  2. M2 — TryUpdate with atomic conditional-consume
  3. M3 — Rotator with background goroutine, grudgetest fakes
  4. M4 — SuggestLevels tuning helper
  5. M5 — SipHash for adversarial key spaces

Performance

Apple Silicon, L=4, M=100,000:

Operation ns/op allocs/op
Update ~111 0
Query ~117 0
UpdateAndQuery ~132 0

Cell payload: ~6.4 MB per generation (L × M × cell size).