From ddf595bd38cbea05c3b8196d2d14c7b271e33e79 Mon Sep 17 00:00:00 2001 From: Sam Shaplygin Date: Thu, 27 Aug 2026 21:43:08 +0200 Subject: [PATCH] chore: stop shipping working material in the repository CLAUDE.md, codemaps/, .reports/, .vscode/ and a debug-probe test in the metrics module were tracked, so every clone carried notes that describe how the project was built rather than how the library works. CLAUDE.md in particular had grown to 68K of stage history, and it drifted: several claims in it were corrected only after an audit, and one had been wrong since the version it described. Untracked rather than deleted -- the files stay on disk as working notes and as the material the retrospective draws on. Documentation a reader or an agent should rely on lives in docs/. lfu/simplelfu/lfu_test.go cited "CLAUDE.md rule 5" for the no-panic rule; with the file gone from the repository that reference points at nothing, so the comment now states the rule instead of citing it. make all passes, including release-check across all ten modules. --- .gitignore | 11 + .reports/codemap-diff.txt | 52 -- .vscode/settings.json | 3 - CLAUDE.md | 1074 ------------------------------ codemaps/architecture.md | 144 ---- codemaps/backend.md | 184 ----- codemaps/data.md | 178 ----- lfu/simplelfu/lfu_test.go | 2 +- metrics/zzz_refute_probe_test.go | 74 -- 9 files changed, 12 insertions(+), 1710 deletions(-) delete mode 100644 .reports/codemap-diff.txt delete mode 100644 .vscode/settings.json delete mode 100644 CLAUDE.md delete mode 100644 codemaps/architecture.md delete mode 100644 codemaps/backend.md delete mode 100644 codemaps/data.md delete mode 100644 metrics/zzz_refute_probe_test.go diff --git a/.gitignore b/.gitignore index 2361f00..1e1be74 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,14 @@ traces/ *.spc.bz2 cluster0* .DS_Store + +# Working material that is not part of the library. These files stay on +# disk -- they are the working notes and the article's exhibits -- but the +# repository does not ship them. Documentation a reader or an agent should +# rely on lives in docs/. +plan/ +CLAUDE.md +codemaps/ +.reports/ +.vscode/ +metrics/zzz_refute_probe_test.go diff --git a/.reports/codemap-diff.txt b/.reports/codemap-diff.txt deleted file mode 100644 index a9e3da5..0000000 --- a/.reports/codemap-diff.txt +++ /dev/null @@ -1,52 +0,0 @@ -codemap update report -generated: 2026-07-26 - -METHOD - The command template specifies TypeScript/Node.js analysis and a - frontend/backend/data file set. This repository is a Go multi-module library - with no frontend and no data schemas. Analysis therefore uses `go doc`, - authoritative for a Go module's API, rather than a JS parser; and no - frontend codemap was generated because there is nothing to describe. - Symbol matching uses word boundaries: a substring test wrongly counts - generic names such as Get/New/Add as documented wherever they appear. - -BEFORE (codemaps dated 2026-02-23, predating milestones 1-6) - exported symbols 106 total, 27 unnamed -> 25.5% - root source files 15 total, 9 unnamed -> 60.0% - modules 9 total, 4 unnamed -> 44.4% - worst dimension 60.0%, above the 30% threshold -> approval requested - user approved: rewrite architecture.md, backend.md, data.md - -AFTER - exported symbols 106 total, 19 unnamed -> 17.9% - . 43 / 6 - lfu 18 / 7 - policies 23 / 2 - policies/arc 2 / 0 - policies/tinylfu 14 / 2 - metrics 6 / 2 - root source files 15 total, 1 unnamed -> 6.7% - generate.go - modules 9 total, 2 unnamed -> 22.2% - examples/basic - examples/migration - -REMAINING UNNAMED SYMBOLS - deliberate - All are methods on types the codemaps already describe (Close, Contains, - Peek, GetType, String, ContainsOrAdd, GetOldest, PeekOrAdd, RemoveOldest) - plus two low-level identifiers. The command asks for high-level structure, - not implementation details, and `go doc` is authoritative and always current - for method sets. Entry-point constructors and sentinel errors WERE added, - because those are API surface a reader needs to get started. - Unnamed: Close, Contains, ContainsOrAdd, DefaultEvictedBufferSize, GetOldest, GetType, Name, Peek, PeekOrAdd, PolicySnapshot, RemoveOldest, String - - generate.go is a go:generate directive only; examples/* are internal demos. - -FILES WRITTEN - codemaps/architecture.md module graph, request path, epoch lifecycle, - ordering invariant, concurrency model - codemaps/backend.md file-by-file, call graphs, entry points, - 'looks wrong but is deliberate' notes - codemaps/data.md enums, Settings, errors, stats scopes, Advice, - core invariant, capacity model - (no frontend.md - not applicable to this project) diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 082b194..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "makefile.configureOnOpen": false -} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 577e0bf..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,1074 +0,0 @@ -# CLAUDE.md - as-cache Project Guidelines - -## Project Overview - -**as-cache** (Adaptive Selection Cache) is a Go library that uses a Multi-Armed Bandit (MAB) statistical approach to select the cache replacement policy at runtime, measuring candidates against real traffic rather than asking the caller to guess. - -Instead of forcing users to choose a fixed eviction algorithm upfront, as-cache runs multiple policies in parallel (shadow caching), measures their hit/miss rates per epoch, and uses Thompson Sampling to select the best-performing policy dynamically. - -**Module:** `github.com/sshaplygin/as-cache` -**Go version:** 1.25+ -**Status:** Pre-1.0. API may change; measured against published traces (see -README "Evidence"); not yet tagged or published -- `make release-check`. - ---- - -## Architecture - -``` -┌─────────────────────────────────────────────────────────────┐ -│ AdaptiveCache │ -│ (Manages active policy + shadow policies, runs Bandit) │ -└─────────────────────────────────────────────────────────────┘ - │ active │ shadows - ▼ ▼ - ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ - │ CacheWrapper│ │ CacheWrapper│ │ CacheWrapper │ - │ (LRU) │ │ (LFU) │ │ (future...) │ - └─────────────┘ └─────────────┘ └─────────────┘ - │ │ - ▼ ▼ - ┌─────────────┐ ┌─────────────┐ - │ hashicorp │ │ lfu/simple │ - │ LRU │ │ LFU │ - └─────────────┘ └─────────────┘ - │ - ▼ - ┌─────────────┐ - │ Bandit │ (Thompson Sampling via stitchfix/mab) - │ SelectPolicy│ - └─────────────┘ -``` - -**Key mechanism:** -1. Every epoch (configurable duration), the background goroutine calls `bandit.SelectPolicy()` -2. Each policy's hit/miss stats feed Beta distribution parameters -3. Bandit samples from distributions, picks the winner, switches active policy -4. Shadow caches receive dummy adds (no real data) to track comparative metrics - ---- - -## File Structure - -``` -as-cache/ -├── CLAUDE.md # This file -├── README.md # Landing page: pitch, quick start, API, doc index -├── docs/ # The long-form documentation the README links to -│ ├── design.md # mechanism, Bandit interface, what is not done -│ ├── configuration.md # Settings, migration, sampling, stability, tuning -│ ├── policies.md # ready-made arms and their caveats -│ ├── advisor-mode.md # ObserveOnly, Advice(), metrics module -│ ├── benchmarking.md # reproducible replays, benchclient, make evidence -│ ├── fleet.md # distributed bandit over Valkey/Redis -│ └── evidence.md # every measured claim and its table -├── go.mod / go.sum # Root module dependencies -├── generate.go # go:generate stringer directive -│ -├── interfaces.go # Core interface definitions -├── models.go # PolicyType, PolicyStats, ShadowStats, GlobalStats -├── errors.go # Sentinel errors returned by NewAdaptiveCache -├── settings.go # Settings + NewAdaptiveCache validation -├── cache.go # AdaptiveCache struct + public cache API -├── epoch.go # Epoch loop, bandit reporting, policy selection -├── migration.go # Migration strategies (cold/warm/gradual) -├── advice.go # ObserveOnly reporting: Advice, PolicyReport -├── shadow.go # Promotion/demotion, shadow duty, value dropping -├── sampling.go # keySampler + miniature capacity maths -├── stability.go # Switch gates (cool-down, min improvement) -├── wrapper.go # CacheWrapper (wraps any Cacher, adds stats) -├── policytype_string.go # Generated: PolicyType.String() via stringer -│ -├── policies/ # Separate module: ready-made policy adapters -│ ├── go.mod / go.sum # depends on root + hashicorp/golang-lru v2.0.6 -│ ├── adapters.go # NewLRU / NewTwoQueue / NewTTL / NewRandomPolicy -│ ├── adapt.go # PartialCacher + AdaptedCache (Resize-by-rebuild) -│ ├── random.go # RandomCache, implemented from scratch -│ ├── ttl.go # TTLCache: own expiry over plain LRU (see below) -│ └── conformance_test.go # shared Cacher/Policy contract suite -│ -├── policies/arc/ # Separate module, SOLELY for patent isolation -│ ├── go.mod / go.sum # depends on hashicorp/golang-lru/arc/v2 -│ └── arc.go # ARC adapter via policies.Adapt -│ -├── policies/fifo/ # Separate module: keeps golang-fifo isolated -│ ├── go.mod / go.sum # root + policies + scalalang2/golang-fifo -│ ├── fifo.go # S3-FIFO and SIEVE over one shared adapter -│ └── index.go # the key index upstream cannot enumerate for us -│ -├── policies/tinylfu/ # Separate module: keeps otter's deps isolated -│ ├── go.mod / go.sum # depends on maypok86/otter/v2 -│ └── tinylfu.go # W-TinyLFU adapter (natively resizable) -│ -├── metrics/ # Separate module: expvar export (stdlib only) -│ ├── go.mod / go.sum -│ └── metrics.go # Advisor, Snapshot, Take, Publish -│ -├── bandit/ # Separate module: ready-made Bandits (stdlib only) -│ ├── go.mod / go.sum # depends on root only -│ ├── thompson.go # Thompson + Greedy, promoted out of bench -│ ├── sample.go # Beta/Gamma draws (Marsaglia-Tsang) -│ ├── store.go # Store interface: Sync / Window / Decide -│ ├── memstore.go # In-process Store: tests and fleet simulation -│ ├── config.go # Config, Mode, EvidenceMode, defaults -│ ├── errors.go # Sentinel errors from NewDistributed -│ ├── fingerprint.go # regime: which replicas may pool with which -│ ├── window.go # decay weighting, evidence cap, Thompson draw -│ ├── distributed.go # the Bandit surface the cache calls (never blocks) -│ ├── coordinate.go # the sync goroutine: one round trip per epoch -│ └── snapshot.go # observability: fallback, leadership, fleet arms -│ -├── docker-compose.yml # Valkey 8 + Redis 7 for `make redis-test` -│ -├── bandit/redis/ # Separate module: keeps go-redis out of bandit -│ ├── go.mod / go.sum # depends on root + bandit + redis/go-redis/v9 -│ ├── TESTING.md # what the real-server runs verified, and did not -│ ├── store.go # bandit.Store over Valkey/Redis -│ ├── scripts.go # Lua: server-clock buckets, SET NX leadership -│ └── keys.go # key schema, hash tags, counter field encoding -│ -├── bench/ # Separate module: workloads + evidence harness -│ ├── workload.go # deterministic zipf/uniform/loop/scan/phase-shift -│ ├── harness.go # replay, Result, tables -│ ├── fleet.go # Shard/Split, paced replay, fleet comparison -│ ├── evidence_test.go # policy comparison + sampling-fidelity check -│ ├── fleet_test.go # does pooling beat deciding alone? -│ ├── timeline_test.go # ActivePolicy() plot over a phase-shift run -│ ├── trace.go # real-trace loaders (Twitter/LIRS/ARC formats) -│ ├── trace_msr.go # MSR Cambridge block I/O: byte ranges -> block keys -│ ├── trace_meta.go # Meta kvcache: header-mapped columns, op_count expansion -│ ├── trace_formats_test.go # fixture tests for both, run by `make test` -│ ├── memory_test.go # memory multiplier + allocations -│ └── tuning_test.go # epoch/migration configuration sweep -│ -├── lfu/ # Separate module: LFU cache -│ ├── go.mod / go.sum -│ ├── lfu.go # Thread-safe LFU wrapper with eviction callbacks -│ ├── lfu_test.go # (stub - needs tests) -│ ├── internal/ -│ │ └── list.go # Doubly-linked list for frequency buckets -│ └── simplelfu/ -│ ├── lfu.go # Core LFU algorithm (O(1) add/get/evict) -│ └── lfu_test.go # (stub - needs tests) -│ -├── site/ # GitHub Pages frontend; static, no build step -│ ├── index.html # landing page: pitch, evidence, docs links -│ ├── bandit-explorer.html # interactive phase-shift replay, self-contained -│ ├── active-policy-timeline.svg # static preview linking to the explorer -│ ├── og.png # 1200x630 social card (og:image, twitter:image) -│ ├── sitemap.xml # both pages; submit via Search Console -│ └── robots.txt # effective only on a custom domain (see file) -│ # deployed by .github/workflows/pages.yml -│ -└── examples/ - ├── basic/ - │ ├── go.mod / go.sum - │ └── main.go # HTTP server demo (GET/SET endpoints) - └── migration/ - ├── go.mod / go.sum - └── main.go # the three migration strategies, side by side -``` - ---- - -## Core Interfaces - -### `Cacher[K, V]` (interfaces.go) -Standard cache interface compatible with `hashicorp/golang-lru/v2`: -```go -Add(key K, value V) bool -Get(key K) (V, bool) -Remove(key K) bool -Keys() []K -Values() []V -Len() int -Peek(key K) (V, bool) -Purge() -Resize(size int) int -Contains(key K) bool -``` - -### `Policy[K, V]` (interfaces.go) -Extends `Cacher` with capacity tracking and stats: -```go -Cap() int -GetStats() PolicyStats -ResetStats() -GetType() PolicyType -``` - -### `Bandit` / `EpochBandit` (interfaces.go) -MAB strategy abstraction. **Both methods run under the cache's write lock, so -an implementation must not block** -- see the distributed-bandit notes below. -`EpochBandit` is an optional extension delivering one whole reporting epoch -per call instead of one arm at a time; a bandit implementing it receives -`RecordEpoch` and never `RecordStats`. - -```go -RecordStats(stats ShadowStats) -SelectPolicy() PolicyType -``` - ---- - -## Key Types - -| Type | Location | Purpose | -|---|---|---| -| `AdaptiveCache[K,V]` | cache.go | Main adaptive cache orchestrator | -| `CacheWrapper[K,V]` | wrapper.go | Wraps any Cacher, adds hit/miss tracking | -| `PolicyType` | models.go | Enum: Undefined, LRU, LFU, TwoQueue, ARC, Random, TTL, TinyLFU, S3FIFO, SIEVE | -| `MigrationStrategy` | models.go | Enum: MigrationCold, MigrationWarm, MigrationGradual | -| `PolicyStats` | models.go | Hits + Misses counters | -| `ShadowStats` | models.go | Per-epoch policy performance | -| `GlobalStats` | models.go | Aggregate statistics | - ---- - -## Dependencies - -| Package | Version | Role | -|---|---|---| -| `hashicorp/golang-lru/v2` | v2.0.6 | LRU/2Q/expirable (policies module). v2.0.7 also builds; the pin is inertia, not a defect | -| `stitchfix/mab` | v0.1.1 | Multi-Armed Bandit (Thompson Sampling) | -| `scalalang2/golang-fifo` | v1.2.0 | S3-FIFO and SIEVE (`policies/fifo` module only). MIT | -| `redis/go-redis/v9` | v9.21.0 | Valkey/Redis client (`bandit/redis` module only) | -| `alicebob/miniredis/v2` | v2.38.0 | Fake Redis for `bandit/redis` tests (test-only) | -| `gonum.org/v1/gonum` | v0.8.2 | Numerical computing (used by mab) | -| `golang.org/x/exp` | indirect | Used by gonum | -| `stretchr/testify` | v1.11.1 | Test assertions (root module, test-only) | - ---- - -## Code Patterns - -### Generics - All cache types use Go generics -```go -type AdaptiveCache[K comparable, V any] struct { ... } -type Cacher[K comparable, V any] interface { ... } -``` - -### Thread Safety -- `sync.RWMutex` in `AdaptiveCache` guards policy switching -- `sync.RWMutex` in LFU `Cache` guards all operations -- Eviction callbacks invoked outside critical sections - -### Context-Based Lifecycle -```go -func New[K comparable, V any](ctx context.Context, ...) *AdaptiveCache[K, V] -// Background goroutine stops on ctx.Done() -``` - -### Shadow Caching Pattern -- Active policy stores real key/value pairs -- Shadow policies receive `Add(key, zeroValue)` calls to track access patterns -- Stats are reset each epoch after bandit records them - ---- - -## Development Commands - -```bash -# Run root package tests -go test ./... - -# Run LFU package tests -cd lfu && go test ./... - -# Run example -cd examples/basic && go run main.go - -# Lint all modules (golangci-lint v2, config in .golangci.yml) -make lint # or: golangci-lint run ./... (per module) -make lint-fix # apply --fix and formatters -make install-tools # install the pinned golangci-lint version - -# Run the bandit/redis suite against real servers rather than miniredis. -# docker-compose.yml brings up both Valkey 8 and Redis 7; the suite runs -# against each, because the adapter claims to support both. -make redis-up # start them and wait for health -make redis-test # start, run against both, tear down -make redis-down # stop them -# or, against a server you already have: -AS_CACHE_REDIS_ADDR=127.0.0.1:6379 sh -c 'cd bandit/redis && go test ./...' - -# Regenerate stringer (after modifying PolicyType in models.go) -go generate ./... - -# Tidy dependencies -go mod tidy -cd lfu && go mod tidy -cd examples/basic && go mod tidy -``` - ---- - -## Current Status & Incomplete Features - -### Implemented -- [x] `AdaptiveCache.Add()` and `Get()` with shadow policy tracking -- [x] `AdaptiveCache.Remove()` - delegates to active policy, propagates to shadows -- [x] `AdaptiveCache.Purge()` - purges all policies -- [x] `AdaptiveCache.Resize()` - resizes all policies, returns total eviction count -- [x] `AdaptiveCache.Contains()` - delegates to active policy -- [x] `AdaptiveCache.Keys()` / `Values()` / `Len()` / `Peek()` - delegate to active policy -- [x] `AdaptiveCache.Stats()` - cumulative: completed epochs (`globalStats`) plus the active policy's in-progress epoch -- [x] Background epoch goroutine with bandit-based policy selection -- [x] `CacheWrapper` with hit/miss statistics -- [x] LFU implementation (simplelfu + thread-safe wrapper) — all methods implemented -- [x] `lfu.Cache`: `Resize`, `ContainsOrAdd`, `PeekOrAdd`, `RemoveOldest`, `GetOldest` -- [x] `simplelfu.LFU`: `Resize`, `GetOldest`, `RemoveOldest` -- [x] Basic example with HTTP server -- [x] Roadmap Milestone 1 (correctness): locked epoch switch in `runEpoch`; atomic - `CacheWrapper` counters; active policy's per-epoch stats reported to the bandit - alongside shadows with all counters reset each epoch (active counts folded into - `AdaptiveCache.globalStats` so `Stats()` stays cumulative and demotion never - leaks active-tenure stats into a shadow epoch); constructor validation with - sentinel errors in `errors.go` (`ErrNilSettings`, `ErrInvalidEpochDuration`, - `ErrNilBandit`, `ErrNilPolicy`, `ErrDuplicatePolicy`); idempotent `Close()` - (`sync.Once`) that waits for the epoch goroutine (`sync.WaitGroup`); explicit - `go vet` CI step and `TestAdaptiveCache_StressAcrossEpochBoundaries` (1ms - epochs, all three migration strategies, full public API under `-race`). - Migration strategies moved verbatim from `cache.go` to `migration.go` to keep - files under the 400-line rule. - -- [x] Roadmap Milestone 2 (overhead reduction), three of four items: - - **Sampled shadow caching** (`sampling.go`, `shadow.go`): `Settings.ShadowSampleRate` - gates the shadow fan-out on `maphash.Comparable(seed, key) < threshold`. One - sampler is shared by every policy so all arms measure the same substream -- - per-policy seeds would make their hit rates incomparable. Shadows resize to - `ceil(rate*Cap)` so each stays a faithful miniature; `MinShadowCapacity` - (default 256) floors that, raising the *effective rate* rather than only the - capacity, and disabling sampling when a cache is too small to host a useful - miniature. - - **Stats deviate from the roadmap wording deliberately**: sampled counts are - NOT scaled up by `1/rate`. That would restore magnitude while inventing - confidence, handing a Beta posterior 20x the evidence collected. Instead the - *active* arm is measured over the same sampled substream - (`activeSampledHits`/`activeSampledMisses`, reported by `selectPolicyLocked`), - so every arm carries equal, honest evidence. `Stats()` still reports real - unsampled traffic; only the bandit sees the sample. - - **Value dropping on demotion** (`demoteLocked`): a demoted policy keeps its - keys (that is its eviction bookkeeping) but its entries are rewritten to the - zero value, unsampled keys removed, then it shrinks to miniature capacity. - Rewriting in `Keys()` order preserves LRU recency and leaves LFU relative - order untouched. Deferred while a gradual window is open, since that window - promotes out of the source's real values. - - **Switch stability** (`stability.go`): `MinHitRateImprovement`, - `SwitchCooldownEpochs`, `MinEpochRequests`, all off at their zero values. - - **Ordering invariant** (documented on `switchLocked`): *every mutation of a - policy must happen while that policy is not the active one.* Mutate the - incoming policy before promoting it, the outgoing one after demoting it. - This is what keeps a caller from ever reading a policy mid-rewrite. - - **Test policies**: `mockPolicy` does not enforce capacity (its `Resize` only - records the new size). That blind spot hid three capacity bugs found in - review. `evictingPolicy` in `evicting_test.go` does enforce it -- use it for - anything whose subject is capacity, resizing, or eviction. - - Measured on an M1 Max with mutex-backed stub policies: `Get` 100 -> 36 ns/op - with one shadow, 145 -> 38 ns/op with three; `Add` 109 -> 52 and 189 -> 57; - mixed parallel 184 -> 85. The structural win is that cost becomes flat in the - number of shadows, so adding policies is nearly free. - - **Deferred: lock-free reads** (`atomic.Pointer` to the active policy). An - adversarial safety analysis found the usual seqlock-retry framing unsound and - surfaced five further hazards -- retry must wrap all six read delegations - (`Values()` on a stale post-drop state returns N zeros at the right length), - retries are not side-effect-free (double-counted stats, double-bumped - recency), the `GetStats`/`ResetStats` split silently discards hits once reads - are unlocked (fixing it changes the public `CacheStats` interface), and - `MigrationGradual` cannot go lock-free since `promoteLocked` mutates from - inside `Get`. Worth its own scoped change. - -- [x] Roadmap Milestone 4 (evidence), synthetic half. `make evidence` replays - the suite; see the README for the tables. The headline finding is negative and - should not be smoothed over: **adaptive selection never beats the best fixed - policy on these workloads**, including phase-shift, where W-TinyLFU wins by - 3.8 points. The `ActivePolicy()` timeline shows the bandit working correctly — - it explores, picks W-TinyLFU, and holds it 90% of the run — but there is no - crossover to exploit because W-TinyLFU is best in both regimes. What the - library does deliver is a bound on the cost of guessing wrong (77.5% vs LRU's - 0.0% on `loop`). That argues for Milestone 5 advisor mode as the primary - product rather than a stepping stone. - - Milestone 2's sampling was validated here: sampled shadows preserve the - policy ranking. (The "1-3 points pessimistic uniformly across arms" figure - once recorded here was measured before the shadow-insert fix and no longer - holds: with shadows filling themselves, every deterministic arm now matches - its standalone replay to within 0.005 at full size, and a sampled rate can - land either side of the true one depending on which slice of the keyspace - the seed selected.) - - Evidence tests are guarded by `testing.Short()` and excluded from - `make test`, which now passes `-short`. Under `-race` the epoch pacing - changes ~15x and the measurements become meaningless. Run `make evidence`. - - The root module ships no `Bandit`; `bench/bandit.go` has a Thompson - sampler (Beta posteriors via Marsaglia-Tsang gamma draws, with discounting - so it can change its mind) and a greedy control. Worth promoting if - advisor mode lands. - -- [x] Roadmap Milestone 4 (evidence), real traces. `./scripts/fetch-traces.sh` - downloads five published traces; none are committed (see `.gitignore`). - Loaders self-test against published record/distinct counts, and the ARC - layout's range expansion is asserted -- each record stands for `blockCount` - accesses, and reading it as one-key-per-line would silently produce a - workload incomparable with the literature. - - **The real traces overturned the synthetic conclusion.** The best fixed - policy varies by trace: 2Q wins on Twitter Twemcache and ARC OLTP, - W-TinyLFU on ARC P3 and the LIRS traces. On OLTP, W-TinyLFU is - second-*worst*. Tuned sensibly (50ms epoch, warm migration), adaptive lands - within ~1 point of the best fixed policy and beats it on P3 by 0.76. - - **Epoch duration is the setting that matters.** A 2ms epoch on a 20k cache - means copying the cache hundreds of times per replay: 13,476 ns/op and a - 7-point hit-rate loss on P3. `MigrationCold` costs 28 points on OLTP. The - stability gates cost 37 points on `loop`, which must re-adapt constantly. - - Memory: six policies cost 2.65x a single LRU, not 6x (shadows hold keys, - never values); 1.32x with sampling. The old README claim was wrong. - (Re-measured at eight policies once the FIFO arms joined the set: 3.92x - and 1.40x. Each is close to double an ordinary arm, because the adapter - keeps its own copy of the key set and S3-FIFO's ghost queue remembers - roughly a further cache's worth. The test now asserts the per-shadow - property - each shadow costs 0.42x a full cache - rather than a total - multiplier that moves every time an arm is added.) -- [x] Roadmap Milestone 5 (advisor mode). `Settings.ObserveOnly` measures every - arm while guaranteeing the cache behaves exactly like the policy it was built - with; `Advice()` reports which policy wins and by how much. The bandit may be - nil in this mode (implementing one is the fiddliest part of using the - library, and nothing is ever selected), and the `EvictPartialCapacityFilling` - capacity gate is bypassed, since it exists to avoid switching on thin - evidence and would otherwise suppress the very measurement being asked for. - The `metrics` module publishes a `Snapshot` through expvar, evaluated on - scrape, with a duplicate-name check so a registration mistake returns an - error instead of panicking the process. - - **`tenureStats`, not lifetime stats.** A policy's measurements are cleared - when it changes role. Accumulating across a role change pooled its active - tenure (full capacity, all traffic) with its shadow tenure (miniature - capacity, a sample), and left the outgoing policy's long history - outweighing the incoming one's short history -- so right after a correct - switch, `Advice` named the policy the cache had just moved away from as - best, for a number of epochs linear in the history length. Do not - "improve" this by accumulating for longer. - - **`Advice.Epochs` counts reporting epochs, not ticks.** The capacity gate - can skip measurement indefinitely, and `epochID` would report thousands of - epochs of evidence behind nothing. - - Ties in `Advice` are broken by `PolicyType`: ranging a map and sorting - stably on hit rate alone made `Best` flap between equally-performing arms - on an unchanged cache. - - `metrics.Publish` needs both the mutex and the `recover`: `expvar.Get` - followed by `expvar.Publish` is check-then-act, and expvar panics on a - duplicate name. - -- [x] LFU added to `policies` (`NewLFU`) and to the evidence suite. Putting it - through the shared conformance suite for the first time found a real bug: - it accepted entries at zero capacity, where every other policy holds nothing. - A pre-existing test asserted the buggy behaviour (that the entry stayed - retrievable) and was corrected -- its purpose was guarding a panic, and the - retrievability assertion was incidental. - - Evidence: LFU is the **best** policy on synthetic `zipf` (73.5%) and the - **worst** on both large real traces (41.4% Twitter, 45.4% OLTP). Synthetic - Zipf holds popularity stationary, which is exactly LFU's assumption; real - traffic shifts and stale frequency counts pin dead entries. This is the - clearest evidence in the repo that synthetic workloads mislead. - -- [x] Distributed bandit (`bandit`, `bandit/redis`). A fleet pools its - per-epoch counts through Valkey/Redis so replicas that individually see too - little traffic to rank their arms can still select. Five findings shaped it: - - **The `Bandit` interface runs under the cache's write lock.** `RecordStats` - and `SelectPolicy` are both called from `selectPolicyLocked`, inside - `runEpoch`'s `c.mu.Lock()`. Go's `RWMutex` queues new readers behind a - waiting writer, so a blocking call there stalls every `Get` in the process - for its duration -- a store timeout becomes a cache outage. `Distributed` - therefore buffers in `RecordEpoch` and serves `SelectPolicy` from an - `atomic.Int64`; all I/O is on its own goroutine. The non-blocking rule is - now documented on `Bandit` itself, and asserted by - `TestDistributed_RecordAndSelectNeverWaitOnTheStore`. - - **Two clocks.** The tuned single-node epoch is 50ms, which is below both a - round trip and any fleet-wide clock agreement. `CoordinationEpoch` is a - separate, seconds-scale clock. Buckets are derived from the *store's* clock - inside Lua (`redis.call('TIME')`), so no replica's clock is ever consulted - and a skewed machine cannot poison a window. Needs Redis 7 / Valkey 7.2 for - effects replication. - - **Decay cannot be applied in place by N writers.** Multiplying a shared - counter once per replica per epoch means a fleet of fifty forgets fifty - times faster than a fleet of one. The store holds plain per-bucket sums; - `aggregate` applies `decay^age` on read. Same arithmetic at any fleet size. - - **Pooling multiplies evidence by fleet size, which kills exploration.** A - Beta posterior narrows with the square root of its evidence, so a thousand - replicas produce draws that always return the same arm. `capEvidence` - bounds the effective sample size, preserving the rate exactly. - `TestCapEvidence_RestoresExploration` is the demonstration: uncapped, a - marginally worse arm is drawn zero times in 200. - - **The active/shadow role gap is why `ModeLeader` is the default.** The - active arm is measured at full capacity, shadows on miniatures whenever - sampling is on. Under leader election every replica has the same arm in - the flattering role so the bias cancels on summing; under - `ModeSharedPosterior` it is asymmetric and compounds with deployment share. - `EvidenceShadowOnly` removes it and is rejected under `ModeLeader`, where - the fleet-wide active policy is nobody's shadow and would have no evidence - at all (`ErrShadowOnlyUnderLeader`). - - **Replicas only pool when they measure the same thing.** `Namespace` is - suffixed with a fingerprint of arms + capacity + sample rate. Pooling a - 1000-entry cache's hit rate with a 100-entry one produces a number that - describes neither, and nothing in the counts would reveal it. Epoch - duration is deliberately *not* in the fingerprint: reporting twice as often - contributes twice the counts at the same rate, and rates are what is - compared. - - **The adapter suite runs against miniredis by default and against real - servers via `make redis-test`.** A fake can be too permissive about - precisely what this leans on -- `TIME` inside a script, `SET NX PX`, and - `HINCRBY` on a key the script names itself rather than declaring in `KEYS` - -- so `docker-compose.yml` brings up both Valkey 8 and Redis 7 and the - suite runs against each. Testing one engine while documenting two is how a - support claim quietly stops being true. Verified against Valkey 8.1.9 and - Redis 7.4.10: 20 tests, 0 skips, 0 failures on each. See - `bandit/redis/TESTING.md`, which also records what is *not* covered - (Redis Cluster, failover, Redis 6) and the zsh word-splitting trap that - makes a hand-run two-engine loop silently test the fake instead. - - Root-module changes this needed, both additive: the optional `EpochBandit` - interface (a bandit that must key evidence externally needs the epoch - boundary, the epoch id and which arm was active -- none of which the - per-arm `ShadowStats` stream carries), and `EpochReport.Capacity` / - `SampleRate` for the fingerprint. - -- [x] **Bug found and fixed: an unrecognised bandit selection panicked the - process.** `runEpoch` switched to whatever `SelectPolicy` returned without - checking the cache actually had it; `migrateData` then looked the missing - policy up in the map and dereferenced a nil interface. `Undefined` is the - natural return from a bandit that has not formed an opinion -- which a - distributed one does for every epoch before its first sync -- so this was - reachable in normal operation. Now guarded by `hasPolicy`; an unrecognised - selection means no change, which is what the docs already implied. - -- [x] **Bug found and fixed: the default migration strategy stopped purging - shadow zeros.** When `MigrationStrategy` was renumbered to `iota + 1`, its - zero value -- the documented default -- stopped matching any case in - `migrateData`'s switch, so a switch skipped purging the incoming policy's - zero-value shadow entries and served them to callers as real data. Cold is - now the `default:` arm rather than a named case, so no strategy value can - fall through the one step every strategy must take. - `TestMigration_DefaultStrategyNeverServesAShadowZero` covers all four values. - The same renumbering hit `bandit.Mode`, where a zero value claimed no - leadership and followed no leader; `validate` now resolves it explicitly. - -- [x] Evidence: **pooling helps only in the regime it was built for.** Paced to - ~8 requests per cache epoch per replica, a pooled fleet gains 3.6 points over - replicas deciding alone (58.17% vs 54.52%), and the mechanism is visible in - the endings: starved replicas scatter across 7 policies, the pooled fleet - holds 2. Unstarved, pooling *loses* -- about a point on zipf, 3.1 points on a - fleet whose replicas serve different workloads, where one fleet-wide policy - is a compromise nobody wanted. Re-measured after the shadow-insert fix; the - coordination sweep changed sign at the fast end (10ms +0.25, 25ms +0.47, 50ms - -0.46, 200ms -2.52 vs local), so coordinating often is now worth a fraction - of a point rather than always costing. - - **The unpaced fleet tests measure the wrong regime.** `Replay` runs flat - out, so it delivers thousands of requests per epoch however small the - workload; a smaller workload just finishes sooner. `ReplayPaced` holds a - request rate, which is the only way to reproduce thin traffic, and it costs - wall-clock time to do so. Do not "speed up" the paced test by unpacing it. - - The coordination-epoch sweep shows most of the unstarved loss is the fleet - getting fewer chances to change its mind, and the fastest settings are the - ones a real round trip makes most expensive. These replays use `MemStore`, - so coordination is free in a way it will not be in production. - - `bench/bandit.go` is gone; `bench` imports the `bandit` module. - -### Incomplete / TODO - -- [x] Data migration between policies on switch — `MigrationStrategy` in `Settings` (`MigrationCold` default, `MigrationWarm` copies all keys from old active to new active) -- [x] Unit tests for LFU packages (simplelfu: 98.3% coverage, lfu wrapper: 100% coverage) -- [x] Unit tests for root package (cache_test.go: 93.6% coverage -- CacheWrapper, AdaptiveCache delegated methods, tryChangePolicy, epoch-based switching, constructor edge cases, concurrent access) -- [x] Roadmap Milestone 3 (policy coverage), two of three items — LRU, 2Q, - Random, TTL and ARC adapters. Four findings shaped the design: - - **ARC is patented by IBM** (US 6,996,676). Upstream `hashicorp/golang-lru` - split it into its own module in v2 for that reason; `policies/arc` keeps - that split so importing `policies` never pulls a patented implementation - into a build. Do not merge it into `policies` for convenience. - - **Neither 2Q nor ARC satisfies `Cacher`**: their `Add`/`Remove` return - nothing and neither has `Resize`, which the miniature-shadow mechanism - depends on. One shared `policies.Adapt` covers both. Its `Resize` rebuilds - the cache, discarding the algorithm's learned adaptation state — so an - adapted policy resized every epoch would stay permanently unadapted and - under-report its own hit rate. - - **`TTLCache` deliberately does NOT use `expirable.LRU`.** Three defects - made that untenable, all found in review: its `Get`/`Peek` return - `(zeroValue, true)` for an expired-but-unreaped entry (a bare `return` over - an already-true named result) -- a zero-value leak, the one invariant this - library is built on; its `Values` returns a full-length slice padded with - trailing zeros that no longer line up with `Keys`; and `NewLRU` starts a - reaper goroutine per cache with no way to stop it, leaking the goroutine - and the whole cache forever. `TTLCache` instead stores a `ttlEntry{value, - expiresAt}` in a plain `lru.Cache` and expires lazily on read. It also - makes size 0 mean *empty*, where `expirable` documents 0 as *unlimited* -- - which, since shadows are resized automatically, would have turned a - bounded shadow into an unbounded one. - - **`Keys()` order is not portable.** 2Q returns frequent-then-recent, ARC - returns recent-then-frequent; neither is a global recency order. So - `AdaptedCache.Resize` replays every entry and lets the rebuilt cache pick - its own victims -- which entries survive a shrink is explicitly not - meaningful. Any "keep the tail" rule is right for one cache and exactly - backwards for the other. - - **`RandomCache.Add` must evict before inserting.** Inserting first puts the - caller's own write into the victim draw, losing it with probability - 1/(size+1) -- a write accepted and gone before the next read. No other - policy here does that, and no conformance test caught it until one was - added that fills a cache and then reads the fresh key back. - - **golang-lru v2.0.7 builds fine; an earlier note here said otherwise and - was wrong.** The claim was that its published `simplelru` imports - `.../simplelru/internal`, which the module does not contain. What actually - happened is that one machine's module cache held a corrupted extraction of - v2.0.7 whose `simplelru/lru.go` line 9 read - `github.com/hashicorp/golang-lru/v2/simplelru/internal`, where the - published file reads `github.com/hashicorp/golang-lru/v2/internal`. The - v2.0.6 and v2.0.7 zips have identical file lists and both contain - `internal/list.go`; a build against v2.0.7 with an empty `GOMODCACHE` - succeeds. The pin at v2.0.6 is therefore only inertia, not a defence. - - The lesson is about the evidence, not the version: "verified against the - checksum database" was asserted but never actually run. When a - dependency looks broken, compare the module zip from `proxy.golang.org` - against the extracted copy in `GOMODCACHE` before concluding anything is - upstream -- `go mod verify` does not re-hash an already-extracted tree. -- [x] W-TinyLFU arm (`policies/tinylfu`, over `maypok86/otter` v2) — the - baseline that actually needs beating. otter is natively resizable, so unlike - 2Q and ARC this arm never rebuilds and keeps its frequency sketch. Caveat: - otter reports an *approximate* size, so `Len()` is approximate and the - `EvictPartialCapacityFilling=false` capacity gate (which compares `Len()` to - `Cap()` for exact equality) may not fire — set it to true when this arm is in - play. -- [x] README Idea section, rewritten to describe the mechanism as built: - per-request (active serves, sampler gates, shadows take keys only) and - per-epoch (equal evidence per arm, bandit names an arm, stability gates and - migration). The stale `## TODO` section, whose two items both shipped in - 0.1.0, is replaced by `## What is not done` -- lock-free reads, unsteppable - wall-clock epochs, no adaptive sizing, no production use. - -- [x] CI covers every module. `bandit` and `bandit/redis` were absent from both - matrices in `.github/workflows/ci.yml`, so the newest and most - concurrency-sensitive module in the repo was checked by nothing but a local - `make test`. Both matrices now mirror the Makefile's `MODULES` (keep them in - step). A separate `redis` job runs the `bandit/redis` suite against Valkey 8 - and Redis 7 service containers -- the CI equivalent of `make redis-test` -- - because miniredis is exactly the kind of fake that is too permissive about - what this adapter leans on. `AS_CACHE_REDIS_ADDR` is set as a step `env` - rather than exported inline, which is the same word-splitting trap - `bandit/redis/TESTING.md` documents: get it wrong and the job silently tests - the fake while reporting that it tested a server. Verified green on the merge - commit: 24 jobs, both engines included. - -- [x] `release-check` runs in CI and in `make all`. It ran in neither: this - file claimed it was "part of `make all`" while the target was `fmt vet lint - test`, so the one gate that catches an unreleasable module was purely - opt-in, and the failure it guards against is invisible until a stranger's - `go get` fails. Both are now wired up, and the claim is true. - -- [x] **Request-counted epochs and the `benchclient` module**, so the cache can - enter a benchmark suite (`maypok86/benchmarks`) and produce a number worth - quoting. Four findings, in the order they surfaced: - - **`Settings.EpochRequests` ends an epoch every N `Get` calls.** A - wall-clock epoch makes adaptation a function of machine speed: replaying - one trace on a loaded machine re-evaluates fewer times and lands on a - different hit rate. `Get` is the unit because hits and misses are recorded - there and nowhere else. The counter subtracts the limit rather than - resetting to zero, so requests arriving mid-crossing are still counted, and - the trigger compares `== limit` so exactly one goroutine runs each epoch. - It runs on the caller's goroutine, outside every lock the `Get` took -- - `runEpoch` needs the write lock. - - **The Thompson bandit was not reproducible even when seeded.** - `SelectPolicy` drew one sample per arm while ranging a map, so map - iteration order decided which arm got which draw. The seed fixes the - sequence of numbers, not the recipients. This is the same defect as the - epoch-report ordering fixed in 0.2.0, in the module written to fix it. - Anything that consumes randomness while iterating a map has this bug. - - **Determinism is a property of the whole stack, and one arm breaks it.** - With the bandit fixed, LRU/LFU/2Q/Random replay identically; W-TinyLFU does - not. Measured with no cache above it, one trace gave three hit counts and - left 527, 504 and 545 entries at capacity 500 -- otter evicts - asynchronously and reports an approximate size. Hence `DefaultArms` - excludes it and `ArmsWithWindowTinyLFU` exists to make that trade - explicitly. Do not "improve" the default by adding the best arm to it. - - **`benchclient` imports nothing from any benchmark suite.** The contract is - five methods and Go interfaces are structural, so a local `contract` - interface plus a `var _` assertion pins it without a dependency, and breaks - the build rather than the registration if it ever drifts. - -- [x] **S3-FIFO and SIEVE arms** (`policies/fifo`, `ascache.S3FIFO` and - `ascache.SIEVE`), one module adapting scalalang2/golang-fifo v1.2.0 (MIT). - Yang, Zhang, Qiu, Yue & Rashmi, "FIFO Queues are All You Need for Cache - Eviction", SOSP '23; Zhang, Yang, Yue, Vigfusson & Rashmi, "SIEVE is Simpler - than LRU", NSDI '24. - - **One module, one adapter, two algorithms.** They come from one dependency - and need the same four missing methods supplied, so duplicating ~300 lines - of deadlock-sensitive glue to give each its own module would have been the - wrong split. `Cache` is parameterised by a constructor returning - `types.Cache`; `NewS3FIFOPolicy` and `NewSievePolicy` choose which. The - module is `policies/fifo`, not `policies/s3fifo`, for that reason. - - **The two fail differently at size zero**, which is why the adapter guards - rather than trusting either: SIEVE's `New` panics outright, S3-FIFO's `Set` - loops "while len >= size, evict" with nothing to evict and never returns. - - **Only S3-FIFO's `Len` is racy** (it reads its queues without locking); - SIEVE's locks. The adapter answers `Len` from its own index for both, so - the hazard is out of reach rather than dependent on which is wrapped. - - **Both arms are driven through a real AdaptiveCache, not just measured.** - `TestArmsDriveAnAdaptiveCacheThroughSwitches` forces a switch every half - round through all four migration strategies, which is the only thing that - exercises what the adaptive layer does to this adapter specifically: - demotion walks `Keys()` and rewrites every sampled key to the zero value - (removing the unsampled ones), promotion resizes back to full capacity, and - warm migration copies out through `Keys()` + `Peek()`. All three lean on - the adapter's own key index and on its rebuild-to-resize. The invariant - under test is the repository's central one: a caller must never read a - shadow's zero value as data, so stored values are `i+1` and any zero read - back is unambiguously a leak. - - **That test must use `EpochRequests`, not `EpochDuration`.** Written with - a 1ms wall-clock epoch it passed alone and failed under `-race` in the - full suite: the race detector slows the epoch goroutine enough that the - run switched too few times to reach both arms. Request-counted epochs - make the number of switches identical on any machine. Anything asserting - "a switch happened" needs them. - - **`bench.TestEveryArmDrivesAnAdaptiveCache`** asserts the whole arm set - builds, reports distinct `PolicyType`s and constructs a real cache. A - duplicate type takes out every adaptive measurement at once (the - constructor rejects the set), and an arm in `FixedPolicies` that is not - reachable through `AdaptiveArms` would be measured alone and silently left - out of the comparison it exists for. - - Its capacity assertion is deliberately loose (1.5x). W-TinyLFU is in the - set and runs over its limit while otter's maintenance catches up; a tight - bound here fails intermittently and measures that arm rather than the - wiring. - - **It is the best fixed policy on one of the six real traces** -- Meta - kvcache, at 69.05% -- and within 0.05 points of the winner on Twitter - (SIEVE 59.78%) and 0.46 of it on OLTP (2Q 68.25%). It is among the cheapest - per operation, because it never reorders anything on a hit. An earlier note - here claimed three of six; that was read off a run predating SIEVE. It also serves **0.00% on LIRS - `loop`**, tied with LRU, LFU, 2Q, TTL, ARC and SIEVE. That is the number worth - remembering about it: **the ghost queue is a hard horizon.** `loop` cycles - 1011 keys through a 500-entry cache, so every reuse distance is 1011; a key - must return while it is still in the small queue or still in the ghost - queue to be promoted, and at 1011 it is neither. On the synthetic `loop`, - whose cycle is 550, it serves ~80%. The entire difference is reuse distance. - - **Written from scratch first, then replaced with the library on request.** - The from-scratch version is gone; what it taught is not: - - **A differential test against a second implementation found a bug nothing - else did.** The hand-rolled ghost queue used the paper's §4.2 - virtual-timestamp approximation, whose window is *positional*, so an - entry removed from it left a dead position - and a ghost hit removes one - on every second-chance admission. The queue steadily held fewer keys than - its capacity claimed and dropped them just before they came back. Every - property test passed: promotion threshold, freq cap, reinsertion, ghost - re-admission, capacity, resize, invariants. Each property held in - isolation; the hit stream did not. **If a policy is ever hand-written - here again, write the differential test first.** - - The paper's `evict()` (`S.size >= 0.1 * cache size`) and the reference - implementation's (main when it overruns or small is empty) agree on every - case a differential run exercised. - - **The adapter has to supply four methods upstream does not have**: `Keys`, - `Values`, `Resize`, `Cap`, plus `Add`'s evicted flag. Four hazards came out - of that, all of them load-bearing: - - **`Resize` rebuilds, discarding the ghost queue and every counter.** - Upstream cannot resize. AdaptiveCache resizes a policy on every promotion - and demotion, so this arm is permanently re-learning if it changes role - often - the exact cost `AdaptedCache.Resize` documents for 2Q and ARC, - now paid by the strongest cheap arm too. This is the single biggest - difference from the from-scratch version, which resized in place. - - **The eviction callback runs under the library's mutex.** golang-fifo - calls it from inside `removeEntry` with its own lock held, on paths this - adapter entered with its own lock held. `onEvicted` therefore must NOT - take the adapter's mutex - a `sync.Mutex` is not reentrant and it would - deadlock on the first eviction. That reasoning holds only because the - cache is built with a TTL of zero and so has no background expiry - goroutine. Do not add a TTL to this adapter. - - **Upstream's `Len()` takes no lock**, so calling it concurrently with a - write is a data race the detector will report. The adapter answers `Len` - from its own index and never calls it. - - **Upstream cannot be built at size zero**: `Set` loops "while len >= - size, evict" with nothing to evict, and never returns. The adapter holds - no cache at all in that state and answers every method itself. - - **Upstream counts a write as an access** (`Set` on a live key raises its - counter), which the paper's algorithm does not. Shadow policies here are - driven with `Add`, so this arm's counters rise faster on shadow duty than - the algorithm specifies. - - **Adding a strong arm made adaptive selection look worse, not better.** - Arms are not free: each thins the evidence every other arm gets per epoch. - It also showed up as a test failure - - `TestFleet_HeterogeneousShardsAreWherePoolingShouldHurt` asserted that - leader election leaves the fleet on exactly one policy, which held 8 runs - out of 8 with seven arms and fails about 1 run in 5 once S3-FIFO and SIEVE - brought the set to nine. The - assertion now covers the property the test is named for (pooling loses to - deciding alone on a heterogeneous fleet) and bounds the convergence claim - instead of pinning it. Do not add arms to the default sets without - re-running the evidence suite. - - It is in `benchclient.DefaultArms`: that set's criteria are deterministic - and unencumbered, and this arm is both. - -- [x] **MSR Cambridge and Meta kvcache trace loaders** (`bench/trace_msr.go`, - `bench/trace_meta.go`), plus a shared `openTraceScanner` the older loaders - now use. - - **Both layouts expand, and that is the whole difficulty.** MSR carries a - byte offset and a byte length and stands for as many block accesses as fit - in the length (a 64 KiB read is 128 accesses at the default 512-byte block, - not one). Meta collapses runs of identical operations into one row and an - `op_count` that CacheBench replays that many times: on the 2M-request - slice, 942,355 read rows expand to 2,000,000 requests. Read either as - one-record-one-request and you get the same keys, far less reuse, and hit - rates that are wrong and entirely plausible. Same trap as `LoadARCTrace`. - - **MSR keys are namespaced by host and disk.** The set covers thirteen - servers whose volumes are numbered from zero independently, so block 40 - means a different thing on each and pooling by block number alone invents - reuse that never happened. Block size defaults to 512 to match Caffeine's - `CambridgeTraceReader`, which makes the numbers comparable with what is - published from that simulator; changing it changes the workload. - - **Meta columns are located by name, not position.** The 2022 release is - `key,op,size,op_count,key_size` and the 2024 one is - `op_time,key,key_size,op,op_count,size,cache_hits,ttl,usecase,sub_usecase`. - Read at the 2022 offsets, the 2024 file's key column is the key size and - every row is discarded as an unrecognised op -- a failure that looks - exactly like an empty trace, which is why an unknown header is an error - rather than a skip. - - **`GET` and `GET_LEASE` are the reads.** SETs are excluded by default: - almost every SET in these files is immediately followed by a GET of the - same key, so replaying both hands every policy a free hit per write. - - **SNIA is not scriptable and the script says so.** `iotta.snia.org` serves - the MSR files behind a click-through licence and a cookie check (and was - unreachable from this machine entirely while this was written), so - `fetch-traces.sh` prints how to fetch them by hand instead of pretending. - Any `msr_.csv[.gz]` in the trace directory is picked up by a glob, - so which volumes somebody takes is their choice. - - **The Meta files are 5-10 GB, and a byte-range request over plain HTTPS is - enough.** No AWS credentials and no CLI: the bucket answers `Range`. The - slice ends mid-line, so every loader here skips a row it cannot parse - rather than failing -- which is also what makes a truncated download - usable. - - **The loader tests run in `make test`, not under `make evidence`.** A - format misread is a correctness bug, not evidence. They are pinned against - fixtures copied out of the real files. - -- [x] **Bug found and fixed: a gradual migration window served shadow zeros as - real data.** Introduced by the shadow-insert fix above and caught by an - adversarial review agent, not by the suite. - - While a `MigrationGradual` window is open, `switchLocked` deliberately does - NOT demote the source: it holds the only copy of everything not yet - promoted. But it is not the active policy either, so `fanOutReadLocked` - treated it as a shadow and filled it with the zero value on a miss. - `promoteLocked` then `Peek`s the source and cannot tell that zero from a - real pending value, so it promoted the zero into the active policy and the - caller got it as a hit. - - **"Not active" is not the same as "is a shadow."** That is the general - lesson: for the duration of a gradual window there are three roles, not - two, and anything iterating `c.policies` and skipping only `activePolicy` - has to decide what it means to do to `migrateFrom`. - - `Add`'s fan-out writes to the source too, but compensates by deleting the - key from `migrationRealKeys`, so the poisoned entry is never promoted. Only - the read path was wrong. - - **The precondition is eviction from the source during the window**, so a - test whose working set fits inside the capacity cannot catch it however - many switches it drives - which is exactly why - `TestArmsDriveAnAdaptiveCacheThroughSwitches/gradual` (500 keys, capacity - 500) passed throughout. `TestGradualMigration_NeverServesAZeroFromTheSource` - uses a working set three times the capacity and fails without the guard. - -- [x] **Bug found and fixed: a shadow policy could only ever acquire a key the - active policy had missed**, which made every shadow measurement meaningless - whenever the active policy was performing well. - - `AdaptiveCache.get` fanned out only `policy.Get(key)` to the shadows. The - single shadow-insert path was `AdaptiveCache.Add` - and a read-through - caller calls `Add` only when the *active* policy missed. So a shadow was - fed from the incumbent's miss stream rather than from the traffic, its - contents went static behind a strong incumbent, and a static cache covering - most of a small keyspace scores extremely well. - - **Measured on `loop` with W-TinyLFU active (94% standalone): LFU, LRU, - SIEVE and TTL all serve 0.00% and were reported at over 90%.** `Advice()` - ranked the arm serving 0% above the arm serving 94% and recommended - switching to it. The distortion's sign and size depend on which arm is - incumbent - Random on zipf went the other way, truly 62.6% and reported at - 3.3% - so it never cancelled in the comparison. - - The fix is `fanOutReadLocked`: a shadow that misses fills itself. After it, - every deterministic arm's shadow figure equals its standalone replay to - within 0.005. `TestShadowsMeasureWhatThePolicyWouldActuallyServe` pins that, - deliberately with the strongest arm active, since that is the only - condition under which the defect appears. - - **The `Add` fan-out now skips a key the shadow already holds.** A shadow's - value is always the zero value, so re-adding a key it has carries no - information - but it is not free: a write counts as an access in several - policies, and SIEVE would mark every freshly filled key as visited, - defeating precisely the one-hit-wonder filtering it is carried for. - `Peek` rather than `Contains`/`Get` so the check disturbs nothing. - - `docs/design.md` had stated the opposite ("Selected keys go to every shadow - as `Add(key, zeroValue)`"), which was true of writes and false of reads. - - It was found by an independent review agent, not by the test suite. Nothing - in the suite compared a shadow's measured rate against the same policy - replayed on its own - the one property a shadow exists to have. - -### Release blocker for the S3-FIFO/SIEVE work - -`benchclient/go.mod` requires `github.com/sshaplygin/as-cache/policies/fifo -v0.3.1`, and **no `policies/fifo/*` tag has ever existed** - the module is new -on this branch. `policies/fifo/go.mod` likewise requires root `v0.3.1`, which -does not contain `ascache.S3FIFO`. Both are correct as pre-release placeholders -under the bottom-up procedure below, which rewrites every `require` at tag -time; neither is safe to tag as-is. - -**`release-check` reports green on both, and should not.** Its version filter -only rejects `^v0\.0\.0`, so a require naming a plausible-but-nonexistent tag -passes. That is precisely the "builds here, unusable by a stranger" failure the -script exists to catch, reached through a hole in it. Verified: resolving -`benchclient` from the proxy fails with "module ... found, but does not contain -package .../policies/fifo". Fix the check to confirm each intra-repo require -resolves to a tag that exists before tagging anything. - -### Releasing 0.2.0 - -Root was tagged `v0.1.0` and `v0.1.1`; **no submodule had ever been tagged**, so -`policies`, `metrics` and the rest were unconsumable at any version. 0.2.0 is -the first release to tag the whole graph, uniformly, including modules that did -not change -- each sibling's `require` has to move to the root's new version -anyway. All eight are published and were verified by resolving them from -proxy.golang.org into a module with no `replace` directives. - -Three things learned doing it, none of which are obvious from the outside. - -- **`release-check` passed a module it should have failed.** Its grep was - anchored to requires written inside a `require (` block, so `policies`' - standalone `require github.com/sshaplygin/as-cache/lfu v0.0.0-000101...` - line was never examined. It reads `go mod edit -json` now, which is the - parse the go command itself uses, so no layout can hide an entry. -- **Verify the chain before pushing a tag, not after.** A bare clone plus - `GOPROXY=direct` and a `GIT_CONFIG_*` url rewrite resolves the require lines - exactly as a stranger would, with no global git config touched and nothing - published. Module versions are immutable once the proxy fetches them. -- **A message-only amend is safe after tagging; a tree change is not.** The - module zip is built from the file tree, so rewriting a commit message and - moving the tags leaves every `h1:` hash identical (checked against the - checksum database before and after). Adding so much as a file under `docs/` - would not -- that is inside the root module, and its hash is already - recorded. Anything landing after a tag has to be a commit after the tag. - -The CHANGELOG now also carries a `[0.1.1]` section. The MPL licence work was -filed under `[0.1.0]`, which shipped before the LICENSE files existed. - ---- - -## Implementation Plan - -### Phase 1: Test Coverage -Priority: fill empty test stubs before adding new features. - -**`lfu/simplelfu/lfu_test.go`** -- DONE (98.3% coverage) -- Test Add/Get/Contains/Peek/Remove/Purge/Keys/Values/Len -- Test eviction behavior (least-frequently-used item removed) -- Test frequency increment on repeated access -- Edge cases: empty cache, single item, duplicate keys -- Bug fixes applied: removed double Freq increment in Add, fixed Keys/Values slice init -- Bucket-index invariant enforced: every bucket in `evictList` holds at least one - entry, and `minFreq` always addresses a live bucket while the cache is non-empty. - `Add`'s eviction path and `removeElement` previously left an emptied bucket in - the map, so a later `minFreq` recompute selected it and panicked on a nil - dereference (`GetOldest`/`RemoveOldest`/`Resize`). Entry removal now funnels - through `detach`/`recomputeMinFreq`, `Add` only evicts when the cache is - non-empty (so `Resize(0)` + `Add` cannot panic), and the lookup helpers degrade - to a miss instead of panicking if the index is ever corrupted. - -**`lfu/lfu_test.go`** -- DONE (100% coverage) - -- `Resize`, `ContainsOrAdd`, `PeekOrAdd`, `RemoveOldest` and `GetOldest` are - covered directly; they are fully implemented (there are no panic stubs) but - were previously untested, which is why the simplelfu bucket-index panics went - unnoticed -- those methods are their public entry points. -- Test thread-safe wrapper around simplelfu -- Test eviction callbacks (buffered channel, DefaultEvictedBufferSize=16) -- Test concurrent Add/Get under race detector -- Concurrent tests for mixed operations, purge-while-reading, keys/values - -**Root package tests (`cache_test.go`)** -- DONE (93.6% coverage) -- `CacheWrapper`: hit/miss stats tracking, GetStats/ResetStats, Cap, Name, GetType, delegated methods -- `AdaptiveCache`: Stats, Resize, Contains, Keys, Values, Len, Peek, ActivePolicy -- `AdaptiveCache`: Add/Get with epoch-based switching, tryChangePolicy (switch, no-switch, skip-when-not-full) -- Test bandit integration with mock bandit (recordingBandit verifies shadow stats delivery) -- Test context cancellation stops background goroutine (Close) -- Constructor edge cases: empty policies, nil policies -- Remove propagates to shadows, Purge clears all policies -- Concurrent access to all delegated read methods under race detector -- Migration strategy tests: Cold, Warm, Gradual (promotion, drain, zero-value safety, concurrent) -- All assertions use `testify/require` (fatal) and `testify/assert` (non-fatal) — no bare `t.Fatal`/`t.Error` -- Note: `tryChangePolicy()` returns `PolicyType` (the bandit's selection), not `bool`; tests compare against `LRU`/`LFU` constants - -### Phase 2: Complete AdaptiveCache Methods -Implement the missing methods that currently return zero values: -- `Remove(key K)`: delegate to active policy, propagate to shadows -- `Purge()`: purge all policies -- `Contains(key K)`: check active policy -- `Keys()` / `Values()` / `Len()` / `Peek()`: delegate to active policy -- `Resize(size int)`: resize all policies -- `Stats()`: aggregate hit/miss from all wrappers - -### Phase 3: Policy Migration on Switch -- DONE - -`MigrationStrategy` enum added to `models.go`; `Settings.MigrationStrategy` field controls behaviour: - -- `MigrationCold` (default, 0): start fresh — simple, causes temporary miss spike -- `MigrationWarm`: on switch, purge zero-value shadow entries from new active policy, then copy all key/value pairs from old active via `Keys()`+`Peek()` - -- `MigrationGradual`: Get-time promotion (during the window, `Get` takes the write lock and promotes the eligible key from the old policy into the new active BEFORE the counted lookup, so promoted requests register as active-policy hits — not misses) + Add-time drain (one key migrated per Add call). Migration window closes when no eligible keys remain (drained, promoted, or removed), on `Purge()`, on the next policy switch, and unconditionally at the next epoch boundary (`runEpoch` calls `closeMigrationLocked` first, so a workload that stops touching pending keys cannot leave the source pinned at full capacity holding real values). Switching away from an empty policy never opens a window. - -Bug fix applied during implementation: all three strategies now purge shadow zero-value entries from the new active policy at switch time, so callers never observe a shadow zero as a real cached value. - -### Phase 4: Additional Policies -Add wrappers for: -- `hashicorp/golang-lru/v2/expirable` (TTL-based) -- `hashicorp/golang-lru/v2` 2Q variant -- Random eviction policy - -Each new policy only needs to implement the `Cacher` interface and be wrapped by `CacheWrapper`. - ---- - -## Testing Guidelines - -- Use `go test -race ./...` to catch race conditions (mandatory given concurrent design) -- Mock the `Bandit` interface to test policy switching deterministically -- Use table-driven tests for cache operation coverage -- Test epoch transitions with short durations (e.g., 1ms) in tests -- Minimum 80% coverage target -- Use `require.*` for assertions that must stop the test on failure (setup, preconditions) -- Use `assert.*` for assertions where the test can continue (value checks, multiple independent conditions) -- Do not use bare `t.Fatal`, `t.Fatalf`, `t.Error`, or `t.Errorf` — use testify instead -- All three test packages (`ascache`, `lfu`, `simplelfu`) use `github.com/stretchr/testify` - ---- - -## Releasing - -This is a multi-module repository, which has one trap that will bite anyone who -tags without knowing about it. - -Every sibling module depends on the others through a `replace` directive -pointing at a local path. That is what makes local development work -- and -**`replace` directives are ignored when a module is consumed as a dependency**. -So `policies/go.mod` requiring `github.com/sshaplygin/as-cache v0.0.0` builds -and tests perfectly here while being impossible for anyone else to use: - - reading github.com/sshaplygin/as-cache/go.mod at revision v0.0.0: - unknown revision v0.0.0 - -`make release-check` catches this; it is part of `make all`. Do not tag until it -passes. - -The licence is MPL 2.0, and **every publishable module carries its own copy**. -A Go module zip contains only its own directory, so a root-only LICENSE never -reaches someone fetching `policies/arc` or any other submodule. Upstream -`hashicorp/golang-lru` ships a LICENSE inside its `arc` submodule for the same -reason. `release-check` verifies this per module. - -Releasing therefore goes bottom-up through the dependency graph -- root, then -`lfu`, then `policies`, then `policies/arc`, `policies/tinylfu` and `metrics` -- -updating each module's `require` to the version its dependency was just tagged -at. `bench` and `examples/*` are internal and are never tagged. - -Go module versions are immutable once the module proxy has fetched them. A -broken `v0.1.0` cannot be replaced, only superseded, so verify the whole chain -resolves before pushing any tag. - -## Rules - -1. No emojis in code, comments, or documentation -2. All public types must have godoc comments -3. Run `go vet ./...` before committing -4. Keep each file under 400 lines; split by responsibility -5. No `panic` except in initialization failures or truly unimplemented stubs -6. Eviction callbacks must not be called while holding a mutex -7. All new policies must implement `Cacher[K, V]` and be wrapped via `CacheWrapper` -8. Update `PolicyType` enum and regenerate stringer when adding new policies diff --git a/codemaps/architecture.md b/codemaps/architecture.md deleted file mode 100644 index e27d3bb..0000000 --- a/codemaps/architecture.md +++ /dev/null @@ -1,144 +0,0 @@ -# Architecture Codemap - -**Last Updated:** 2026-07-26 -**Root module:** `github.com/sshaplygin/as-cache` -**Go version:** 1.25.2 -**Modules:** 9 (6 publishable, 3 internal) - -## Overview - -as-cache selects a cache eviction policy at runtime instead of asking the caller -to choose one. Candidate policies run side by side against the same key stream; -one serves real data while the rest measure comparative hit rates. A Multi-Armed -Bandit picks the active policy each epoch, or -- in `ObserveOnly` mode -- the -cache never switches and simply reports which policy would have served better. - -The core module has no dependencies. Policies and integrations live in companion -modules so a consumer pulls in only what it uses. - -## Module graph - -```text - as-cache (core, no deps) - / | \ - / | \ - policies metrics lfu - / | \ ^ - / | \ | - arc tinylfu (policies also depends on lfu) - \ - bench (internal: depends on all of the above) -``` - -| Module | Role | External deps | LOC | -| --- | --- | --- | --- | -| `.` (root) | cache, epochs, sampling, advice | none | 1592 | -| `lfu` | O(1) LFU implementation | none | 682 | -| `policies` | LRU/LFU/2Q/Random/TTL adapters | `hashicorp/golang-lru/v2` | 1101 | -| `policies/arc` | ARC adapter (patent-isolated) | `hashicorp/golang-lru/arc/v2` | 50 | -| `policies/tinylfu` | W-TinyLFU adapter | `maypok86/otter/v2` | 207 | -| `metrics` | expvar export | none | 171 | -| `bench` | workloads, traces, evidence (internal) | all of the above | 808 | -| `examples/*` | runnable demos (internal) | -- | -- | - -`policies/arc` is a separate module **solely** because ARC is patented by IBM -(US 6,996,676); importing `policies` must never pull a patented implementation -into a build. Upstream `hashicorp/golang-lru` made the same split. - -## Request path - -```text -Caller - | - v -AdaptiveCache.Get(key) - | - |-- sampler.sampled(key)? ---no--> skip shadows entirely - | |yes - | v - | for each shadow: policy.Get(key) (measurement only) - | - v -activePolicy.Get(key) ------------------> value to caller - | - +-- recordActiveSample(hit) (counts only sampled keys, so every - arm is judged on the same substream) -``` - -`Add` mirrors this: shadows receive `Add(key, zeroValue)`, the active policy -receives the real value. **A shadow never holds a real value**, which is what -makes running seven policies cost 2.65x one rather than 7x. - -## Epoch lifecycle - -Driven by a background goroutine on `Settings.EpochDuration`. - -```text -runEpoch() [holds the write lock throughout] - | - 1. closeMigrationLocked() end any gradual window; demote its source - 2. selectPolicyLocked() report every arm to the bandit, reset - | counters, accumulate tenureStats - 3. ObserveOnly? --yes--> stop here; the active policy never changes - | - 4. allowSwitchLocked() stability gates (improvement, cooldown, - | minimum requests) - 5. switchLocked(from, to) promote capacity -> migrate -> activate - | -> demote the outgoing policy - 6. epochID++ -``` - -### The ordering invariant - -Documented on `switchLocked` and load-bearing: - -> Every mutation of a policy must happen while that policy is not the active -> one. - -The incoming policy is resized and migrated into *before* it becomes active; the -outgoing policy has its values dropped only *after* it stops being active. -Reversing either half would let a caller read a policy mid-rewrite and take a -dropped zero for real data. Any future move to lock-free reads rests on this. - -## Key mechanisms - -**Sampled shadows** (`sampling.go`). One `keySampler` per cache, shared by every -policy so all arms measure the same substream. Shadows shrink to -`ceil(rate * Cap)` so each stays a faithful miniature; `MinShadowCapacity` floors -that by raising the *effective rate*, not just the capacity. - -**Migration** (`migration.go`). Cold discards, Warm copies at switch time, -Gradual promotes on read and drains one key per `Add`. A gradual window closes at -the next epoch at the latest. - -**Advice** (`advice.go`). `tenureStats` accumulates per policy and is cleared -when a policy changes role, so a comparison never pools a policy's active tenure -(full capacity, all traffic) with its shadow tenure (miniature, sampled). - -## Concurrency model - -- One `sync.RWMutex` guards the cache. Reads take `RLock`; `Add`, `Remove`, - `Purge`, `Resize` and the epoch take `Lock`. -- Wrapper hit/miss counters and the active-sample counters are `atomic.Int64`, - because reads mutate them while holding only a read lock. -- Underlying policies carry their own locks; the cache lock orders policy - *switching*, not individual policy operations. -- Reads are **not** lock-free. That work is deferred; see `CLAUDE.md`. - -## Layering rules - -- The root module must stay dependency-free. Anything needing a third-party - cache goes in a companion module. -- New policies implement `Cacher[K, V]` and are wrapped by `CacheWrapper`; they - need no change to the core. -- `PolicyType` is a closed enum in the root; adding a policy means extending it - and regenerating the stringer. - -## Related documents - -- `codemaps/backend.md` -- file-by-file structure -- `codemaps/data.md` -- types, enums and their invariants -- `CLAUDE.md` -- development rules and the multi-module release procedure -- `README.md` -- landing page: what it is, when to use it, quick start, API -- `docs/` -- design, configuration, policies, advisor mode, benchmarking, - fleets and the measured evidence behind every claim diff --git a/codemaps/backend.md b/codemaps/backend.md deleted file mode 100644 index c552cf6..0000000 --- a/codemaps/backend.md +++ /dev/null @@ -1,184 +0,0 @@ -# Implementation Codemap - -**Last Updated:** 2026-07-26 - -File-by-file structure. Types and their invariants are in `data.md`; the -component and epoch diagrams are in `architecture.md`. - -## Root module -- `github.com/sshaplygin/as-cache` - -No external dependencies. `testify` is test-only. - -| File | Responsibility | -| --- | --- | -| `doc.go` | package documentation (the pkg.go.dev landing page) | -| `interfaces.go` | `Cacher`, `CacheStats`, `Policy`, `Bandit` | -| `models.go` | `PolicyType`, `MigrationStrategy`, stats structs | -| `errors.go` | sentinel errors returned by the constructor | -| `settings.go` | `Settings` + `NewAdaptiveCache` validation | -| `cache.go` | `AdaptiveCache` struct and the public cache API | -| `epoch.go` | epoch loop, bandit reporting, policy selection | -| `shadow.go` | promote/demote, shadow duty, value dropping, switch | -| `migration.go` | cold/warm/gradual migration | -| `sampling.go` | `keySampler`, miniature capacity maths | -| `stability.go` | switch gates (improvement, cooldown, min requests) | -| `advice.go` | `Advice`, `PolicyReport`, observe-only reporting | -| `wrapper.go` | `CacheWrapper`: hit/miss tracking around any `Cacher` | -| `policytype_string.go` | generated by `stringer`; do not edit | - -### Call graph, hot path - -```go -Get(key) // cache.go - sampler.sampled(key) // sampling.go - [shadows].Get(key) // measurement only - activePolicy.Get(key) - recordActiveSample(hit) // cache.go, atomic counters - // gradual window only: promoteLocked -> migration.go -``` - -### Call graph, control plane - -```go -runAdaptiveSelect() // epoch.go, background goroutine - runEpoch() - closeMigrationLocked() // shadow.go -> demoteLocked - selectPolicyLocked() // epoch.go - policy.GetStats/ResetStats - bandit.RecordStats // every arm, active included - bandit.SelectPolicy - allowSwitchLocked() // stability.go - switchLocked(from, to) // shadow.go - promoteLockedCapacity(to) - migrateData(from, to) // migration.go - activePolicy = to - demoteLocked(from) -``` - -### Things that look wrong but are deliberate - -- `selectPolicyLocked` reports the **active** policy from `activeSampledHits`, - not from its own counters, so every arm is judged on the same substream. -- `demoteLocked` rewrites entries to the zero value in `Keys()` order rather - than purging: that preserves LRU recency and leaves LFU relative order intact. -- `Advice.Epochs` comes from `reportingEpochs`, not `epochID`. The capacity gate - can skip measurement for many ticks, and reporting those as evidence would - overstate it. -- `AdaptedCache.Resize` replays *every* entry and lets the rebuilt cache evict. - `Keys()` order is not portable (2Q returns frequent-then-recent, ARC returns - recent-then-frequent), so no "keep the tail" rule is correct for both. - -## `lfu` -- O(1) LFU - -| File | Responsibility | -| --- | --- | -| `lfu.go` | thread-safe wrapper, eviction callbacks | -| `simplelfu/lfu.go` | core algorithm, frequency buckets | -| `internal/list.go` | doubly-linked list per frequency bucket | - -Invariant: every bucket in `evictList` holds at least one entry, and `minFreq` -always addresses a live bucket while the cache is non-empty. Entry removal -funnels through `detach`/`recomputeMinFreq`. `Add` refuses entries at zero -capacity. - -## `policies` -- ready-made arms - -Depends on the root module, `lfu`, and `hashicorp/golang-lru/v2` (held at -**v2.0.6**; v2.0.7 builds too, see `CLAUDE.md`). - -| File | Provides | -| --- | --- | -| `adapters.go` | `NewLRU`, `NewLFU`, `NewTwoQueue`, `NewTTL`, `NewRandomPolicy` | -| `adapt.go` | `PartialCacher`, `AdaptedCache`, `Adapt` | -| `random.go` | `RandomCache`, from scratch | -| `ttl.go` | `TTLCache`, own expiry over plain LRU | -| `conformance_test.go` | shared contract suite every policy must satisfy | -| `regression_test.go` | guards for specific past defects | - -`Adapt` exists because 2Q and ARC both lack `Resize` and report neither -evictions nor removals. `TTLCache` does **not** use `expirable.LRU`: that type -returns `(zero, true)` for an expired-but-unreaped entry, pads `Values` with -zeros, and leaks a reaper goroutine per cache. - -## `policies/arc` -- patent isolation - -One file wrapping `hashicorp/golang-lru/arc/v2` through `policies.Adapt`. -Separate module so that importing `policies` never pulls in a patented -algorithm. Do not merge it into `policies` for convenience. - -## `policies/tinylfu` -- W-TinyLFU - -Wraps `maypok86/otter/v2`. Natively resizable, so unlike the adapted policies it -never rebuilds and keeps its frequency sketch. `Len()` is **approximate** -(otter reports an estimate), so the `EvictPartialCapacityFilling=false` capacity -gate may not fire; set it true when this arm is in play. - -## `metrics` -- observability - -Standard library only. `Advisor` is an interface rather than a generic -parameter so any `AdaptiveCache[K, V]` satisfies it. `Publish` registers an -`expvar.Func` (evaluated on scrape) behind a mutex plus a `recover`, because -`expvar.Get` then `expvar.Publish` is check-then-act and expvar panics on a -duplicate name. - -## `bench` -- evidence (internal, never tagged) - -| File | Provides | -| --- | --- | -| `workload.go` | deterministic zipf/uniform/loop/scan/phase-shift generators | -| `trace.go` | loaders for Twitter, LIRS and ARC trace formats | -| `bandit.go` | Thompson (Beta posteriors, discounted) and greedy bandits | -| `harness.go` | `Replay`, `Result`, `FixedPolicies`, tables | -| `evidence_test.go` | policy comparison, sampling-fidelity sweep | -| `timeline_test.go` | `ActivePolicy()` plot over a phase shift | -| `memory_test.go` | memory multiplier and allocations | -| `tuning_test.go` | epoch/migration configuration sweep | -| `trace_test.go` | real-trace evidence + parser self-tests | - -Evidence tests are guarded by `testing.Short()` and excluded from `make test`, -which passes `-short`. Under `-race` epoch pacing changes enough to make the -measurements meaningless. Run `make evidence`. - -The root module ships no `Bandit`; `bench/bandit.go` has one short enough to -copy. - -## Entry points - -What to call, per module. Methods on these types are deliberately not -enumerated here -- `go doc` is authoritative and always current. - -```go -// root -ascache.NewAdaptiveCache(policies, bandit, settings) (*AdaptiveCache, error) -ascache.NewCache(cacher, policyType, size) *CacheWrapper // wrap your own - -// policies -policies.NewLRU / NewLFU / NewTwoQueue(size) (Policy, error) -policies.NewTTL(size, ttl) Policy -policies.NewRandomPolicy(size) Policy -policies.Adapt(size, build) (*AdaptedCache, error) // adapt your own -policies.NewRandom(size) *RandomCache // concrete types -policies.NewTTLCache(size, ttl) *TTLCache - -// separate modules -arc.NewPolicy(size) (Policy, error) -tinylfu.NewPolicy(size) (Policy, error) - -// lfu -lfu.New(size) (*Cache, error) -lfu.NewWithEvict(size, onEvicted) (*Cache, error) - -// metrics -metrics.Take(cache) Snapshot -metrics.Publish(name, cache) error -``` - -## Build and checks - -```bash -make all # fmt, vet, lint, test, release-check -make test # go test -race -short, every module -make evidence # replay workloads, print the comparison tables -make release-check # licence + unresolvable sibling requires -make lint # golangci-lint v2, shared root config -``` diff --git a/codemaps/data.md b/codemaps/data.md deleted file mode 100644 index 55240d5..0000000 --- a/codemaps/data.md +++ /dev/null @@ -1,178 +0,0 @@ -# Data Models Codemap - -**Last Updated:** 2026-07-26 - -Types, their zero values, and the invariants that hold them together. File -structure is in `backend.md`. - -## Enums - -### PolicyType (`models.go`) - -```go -type PolicyType uint - -const ( - Undefined PolicyType = iota // 0 -- zero value, names no policy - LRU // 1 - LFU // 2 - TwoQueue // 3 -- scan-resistant - ARC // 4 -- patented; adapter is a separate module - Random // 5 -- the control arm worth beating - TTL // 6 -- expiry as well as recency - TinyLFU // 7 -- W-TinyLFU -) -``` - -Closed enum. Adding a policy means extending it **and** running -`go generate ./...` to regenerate `policytype_string.go`. - -`Undefined` is meaningful: a bandit returning it selects nothing, which is how -`observerBandit` works in `ObserveOnly` mode. - -### MigrationStrategy (`models.go`) - -```go -type MigrationStrategy uint - -const ( - MigrationCold MigrationStrategy = iota // 0 -- start empty - MigrationWarm // 1 -- copy at switch time - MigrationGradual // 2 -- promote on read, drain on Add -) -``` - -Every strategy purges the incoming policy first: it arrives from shadow duty -holding zero values, and serving one as real data would break the core -invariant. - -Measured cost: `MigrationCold` loses 28 points of hit rate on the ARC OLTP -trace. `MigrationWarm` is the sane default. - -## Configuration - -### Settings (`settings.go`) - -Every field is inactive at its zero value, so `Settings{EpochDuration: d}` is a -complete configuration. - -| Field | Type | Zero means | -| --- | --- | --- | -| `EpochDuration` | `time.Duration` | **required**, must be > 0 | -| `EvictPartialCapacityFilling` | `bool` | only switch once the cache is full | -| `MigrationStrategy` | `MigrationStrategy` | `MigrationCold` | -| `MinHitRateImprovement` | `float64` | no improvement gate | -| `SwitchCooldownEpochs` | `int64` | may switch every epoch | -| `MinEpochRequests` | `int64` | no minimum evidence | -| `ShadowSampleRate` | `float64` | 1 -- shadows mirror every key | -| `MinShadowCapacity` | `int` | `DefaultMinShadowCapacity` (256) | -| `ObserveOnly` | `bool` | the cache may switch | - -`MinHitRateImprovement` is a **fraction** in [0,1], matching `Advice.Improvement` -(0.02 = two points), not a percentage. - -Under `ShadowSampleRate`, `MinEpochRequests` counts *sampled* requests: at rate -0.05 a threshold of 100 is reached after roughly 2000 real requests. - -## Constructor errors (`errors.go`) - -`NewAdaptiveCache` returns these as sentinels; compare with `errors.Is`. - -| Error | Cause | -| --- | --- | -| `ErrEmptyPolicies` | no policies supplied | -| `ErrNilSettings` | `settings` is nil | -| `ErrInvalidEpochDuration` | `EpochDuration` <= 0 (`time.NewTicker` would panic) | -| `ErrNilBandit` | `bandit` is nil and `ObserveOnly` is false | -| `ErrNilPolicy` | a nil entry in the policies slice | -| `ErrDuplicatePolicy` | two policies report the same `PolicyType` | - -Validation order matters: settings is checked before the bandit, because a nil -bandit is legal when `settings.ObserveOnly` is set. - -## Statistics - -```go -type PolicyStats struct{ Hits, Misses int64 } // one policy -type GlobalStats struct{ Hits, Misses int64 } // what the cache served -type ShadowStats struct { // one epoch report to the bandit - Policy PolicyType - Hits, Misses int64 -} -``` - -Three different scopes, easily confused: - -| Source | Covers | Sampled? | -| --- | --- | --- | -| `AdaptiveCache.Stats()` | all traffic the cache served, cumulative | never | -| `ShadowStats` (to bandit) | one policy, since its last report | yes, if enabled | -| `PolicyReport` (in `Advice`) | one policy, current role only | yes, if enabled | - -`Stats()` is cumulative across epochs because per-policy counters are reset -after each report; the active policy's totals fold into `globalStats` first. - -## Advice - -```go -type Advice struct { - Epochs int64 // REPORTING epochs, not elapsed ticks - Active PolicyType - Best PolicyType - Improvement float64 // fraction; 0 when Best == Active - Sampled bool - SampleRate float64 - Reports []PolicyReport // best hit rate first, ties broken by PolicyType -} - -type PolicyReport struct { - Policy PolicyType - Hits, Misses int64 // current role only - Active bool -} -``` - -Invariants worth knowing before changing this: - -- `Reports` is sorted by hit rate with a **deterministic tie-break** on - `PolicyType`. Ranging a map and sorting stably on rate alone made `Best` flap - between equally-performing arms on an unchanged cache. -- `tenureStats` is cleared for both policies on a switch. Pooling a policy's - active tenure with its shadow tenure mixes full capacity over all traffic with - a miniature over a sample, and lets a long history outweigh a short one -- so - advice recommended reverting a correct switch. -- `Epochs` counts epochs that measured something. The capacity gate can skip - measurement indefinitely. - -## Metrics snapshot (`metrics` module) - -`Snapshot` is JSON-serialised for expvar. `Hits`/`Misses`/`HitRate` are real -unsampled traffic; the per-policy figures inside `Policies` are sampled when -sampling is on. The series worth graphing is `active_policy`; the one worth -alerting on is `improvement`. - -## Core invariant - -> A caller must never observe a zero value, or any value never stored, as if it -> were real cached data. - -Everything below serves it: - -- Shadows receive `Add(key, zeroValue)` and hold no real values. -- Every migration purges the incoming policy before it serves anyone. -- Demotion drops values only *after* the policy stops being active. -- Gradual migration promotes a key *before* the counted lookup, so a served - request is a hit rather than a miss against a zero. - -## Capacity model - -Each policy has two capacities: - -- `nominalCap` -- as the caller built it; restored on promotion. -- `shadowCap` -- `ceil(effectiveRate * nominalCap)`; used while shadowing. - -`shadowCapacity` applies the `MinShadowCapacity` floor by raising the effective -*rate*, preserving `shadowCap/nominalCap == rate`. `scaledCapacity` (used by -`Resize`) does **not** apply the floor: the sampler's rate is fixed for the -cache's life, so flooring capacity alone would leave shadows simulating a larger -cache than the real one and bias every arm against the active policy. diff --git a/lfu/simplelfu/lfu_test.go b/lfu/simplelfu/lfu_test.go index ff3a0d8..43b9184 100644 --- a/lfu/simplelfu/lfu_test.go +++ b/lfu/simplelfu/lfu_test.go @@ -585,7 +585,7 @@ func TestRemove_RepairsMinFreqWhenBucketEmpties(t *testing.T) { // The bucket index is an internal invariant that the code above is responsible // for upholding. These white-box cases corrupt it deliberately to prove the // lookup helpers degrade to a miss rather than dereferencing a nil bucket, -// keeping the package free of panics (CLAUDE.md rule 5). +// keeping the package free of panics outside initialisation. func TestCorruptedIndex_DegradesToMissInsteadOfPanic(t *testing.T) { t.Run("minFreq addresses a missing bucket", func(t *testing.T) { c, err := NewLFU[string, int](2, nil) diff --git a/metrics/zzz_refute_probe_test.go b/metrics/zzz_refute_probe_test.go deleted file mode 100644 index fb59a77..0000000 --- a/metrics/zzz_refute_probe_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package metrics_test - -import ( - "testing" - "time" - - "github.com/stretchr/testify/require" - - ascache "github.com/sshaplygin/as-cache" - "github.com/sshaplygin/as-cache/metrics" - "github.com/sshaplygin/as-cache/policies" -) - -// TestProbe_IdleCacheSnapshot prints what an observe-only cache that has served -// NOTHING publishes, after several epochs have elapsed. -func TestProbe_IdleCacheSnapshot(t *testing.T) { - lru, err := policies.NewLRU[string, int](1000) - require.NoError(t, err) - twoQ, err := policies.NewTwoQueue[string, int](1000) - require.NoError(t, err) - - cache, err := ascache.NewAdaptiveCache[string, int]( - []ascache.Policy[string, int]{lru, twoQ}, nil, - &ascache.Settings{EpochDuration: 5 * time.Millisecond, ObserveOnly: true}, - ) - require.NoError(t, err) - t.Cleanup(func() { _ = cache.Close() }) - - // Snapshot BEFORE any epoch has run at all. - t.Logf("t=0 (no epoch yet): %s", metrics.Take(cache).String()) - - // Let several epochs elapse with zero traffic. - deadline := time.Now().Add(300 * time.Millisecond) - for time.Now().Before(deadline) { - if cache.Advice().Epochs > 3 { - break - } - } - - snap := metrics.Take(cache) - t.Logf("idle (epochs=%d): %s", snap.Epochs, snap.String()) - t.Logf("advice struct: %+v", cache.Advice()) - t.Logf("advice string:\n%s", cache.Advice().String()) - - // Is the "nothing measured" state distinguishable from a genuine 0%? - t.Logf("hits=%d misses=%d hits+misses=%d best=%q active=%q improvement=%v", - snap.Hits, snap.Misses, snap.Hits+snap.Misses, - snap.BestPolicy, snap.ActivePolicy, snap.Improvement) -} - -// TestProbe_GenuineZeroHitRate shows what a cache that genuinely misses on -// everything publishes, for comparison with the idle case. -func TestProbe_GenuineZeroHitRate(t *testing.T) { - lru, err := policies.NewLRU[string, int](10) - require.NoError(t, err) - twoQ, err := policies.NewTwoQueue[string, int](10) - require.NoError(t, err) - - cache, err := ascache.NewAdaptiveCache[string, int]( - []ascache.Policy[string, int]{lru, twoQ}, nil, - &ascache.Settings{EpochDuration: 5 * time.Millisecond, ObserveOnly: true}, - ) - require.NoError(t, err) - t.Cleanup(func() { _ = cache.Close() }) - - // Every key distinct -> every Get is a miss. - for i := 0; i < 5000; i++ { - cache.Get(string(rune(i)) + "-never-stored") - } - - snap := metrics.Take(cache) - t.Logf("all-miss: %s", snap.String()) - t.Logf("hits=%d misses=%d hit_rate=%v", snap.Hits, snap.Misses, snap.HitRate) -}