as-cache (Adaptive Selection Cache) is an experimental Go library that uses a Multi-Armed Bandit (MAB) statistical approach to automatically select the optimal cache replacement policy at runtime.
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.21+
Status: Experimental
┌─────────────────────────────────────────────────────────────┐
│ 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:
- Every epoch (configurable duration), the background goroutine calls
bandit.SelectPolicy() - Each policy's hit/miss stats feed Beta distribution parameters
- Bandit samples from distributions, picks the winner, switches active policy
- Shadow caches receive dummy adds (no real data) to track comparative metrics
as-cache/
├── CLAUDE.md # This file
├── README.md # User-facing documentation
├── go.mod / go.sum # Root module dependencies
├── generate.go # go:generate stringer directive
│
├── interfaces.go # Core interface definitions
├── models.go # PolicyType, PolicyStats, ShadowStats, GlobalStats
├── cache.go # AdaptiveCache implementation
├── wrapper.go # CacheWrapper (wraps any Cacher, adds stats)
├── policytype_string.go # Generated: PolicyType.String() via stringer
│
├── 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)
│
└── examples/
└── basic/
├── go.mod / go.sum
└── main.go # HTTP server demo (GET/SET endpoints)
Standard cache interface compatible with hashicorp/golang-lru/v2:
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) boolExtends Cacher with capacity tracking and stats:
Cap() int
GetStats() PolicyStats
ResetStats()
GetType() PolicyTypeMAB strategy abstraction:
RecordStats(stats ShadowStats)
SelectPolicy() PolicyType| 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 |
MigrationStrategy |
models.go | Enum: MigrationCold, MigrationWarm |
PolicyStats |
models.go | Hits + Misses counters |
ShadowStats |
models.go | Per-epoch policy performance |
GlobalStats |
models.go | Aggregate statistics |
| Package | Version | Role |
|---|---|---|
hashicorp/golang-lru/v2 |
v2.0.7 | LRU reference implementation |
stitchfix/mab |
v0.1.1 | Multi-Armed Bandit (Thompson Sampling) |
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) |
type AdaptiveCache[K comparable, V any] struct { ... }
type Cacher[K comparable, V any] interface { ... }sync.RWMutexinAdaptiveCacheguards policy switchingsync.RWMutexin LFUCacheguards all operations- Eviction callbacks invoked outside critical sections
func New[K comparable, V any](ctx context.Context, ...) *AdaptiveCache[K, V]
// Background goroutine stops on ctx.Done()- 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
# Run root package tests
go test ./...
# Run LFU package tests
cd lfu && go test ./...
# Run example
cd examples/basic && go run main.go
# 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-
AdaptiveCache.Add()andGet()with shadow policy tracking -
AdaptiveCache.Remove()- delegates to active policy, propagates to shadows -
AdaptiveCache.Purge()- purges all policies -
AdaptiveCache.Resize()- resizes all policies, returns total eviction count -
AdaptiveCache.Contains()- delegates to active policy -
AdaptiveCache.Keys()/Values()/Len()/Peek()- delegate to active policy -
AdaptiveCache.Stats()- returns cumulative hit/miss from active policy - Background epoch goroutine with bandit-based policy selection
-
CacheWrapperwith hit/miss statistics - LFU implementation (simplelfu + thread-safe wrapper) — all methods implemented
-
lfu.Cache:Resize,ContainsOrAdd,PeekOrAdd,RemoveOldest,GetOldest -
simplelfu.LFU:Resize,GetOldest,RemoveOldest - Basic example with HTTP server
- Data migration between policies on switch —
MigrationStrategyinSettings(MigrationColddefault,MigrationWarmcopies all keys from old active to new active) - Unit tests for LFU packages (simplelfu: 100% coverage, lfu wrapper: 93.2% coverage)
- Unit tests for root package (cache_test.go: 96.5% coverage -- CacheWrapper, AdaptiveCache delegated methods, tryChangePolicy, epoch-based switching, constructor edge cases, concurrent access)
- Additional policies: Random, 2Q, ARC (mentioned in README but not implemented)
- README Usage and Idea sections
Priority: fill empty test stubs before adding new features.
lfu/simplelfu/lfu_test.go -- DONE (100% 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
lfu/lfu_test.go -- DONE (93.2% coverage; uncovered methods are panic stubs)
- 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 (96.5% coverage)
CacheWrapper: hit/miss stats tracking, GetStats/ResetStats, Cap, Name, GetType, delegated methodsAdaptiveCache: Stats, Resize, Contains, Keys, Values, Len, Peek, ActivePolicyAdaptiveCache: 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) andtestify/assert(non-fatal) — no baret.Fatal/t.Error - Note:
tryChangePolicy()returnsPolicyType(the bandit's selection), notbool; tests compare againstLRU/LFUconstants
Implement the missing methods that currently return zero values:
Remove(key K): delegate to active policy, propagate to shadowsPurge(): purge all policiesContains(key K): check active policyKeys()/Values()/Len()/Peek(): delegate to active policyResize(size int): resize all policiesStats(): aggregate hit/miss from all wrappers
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 viaKeys()+Peek() -
MigrationGradual: Get-time promotion (miss in new active → peek old policy → add to new active) + Add-time drain (one key migrated per Add call). Migration window closes when all keys are drained, onPurge(), or at the next epoch boundary.
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.
Add wrappers for:
hashicorp/golang-lru/v2/expirable(TTL-based)hashicorp/golang-lru/v22Q variant- Random eviction policy
Each new policy only needs to implement the Cacher interface and be wrapped by CacheWrapper.
- Use
go test -race ./...to catch race conditions (mandatory given concurrent design) - Mock the
Banditinterface 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, ort.Errorf— use testify instead - All three test packages (
ascache,lfu,simplelfu) usegithub.com/stretchr/testify
- No emojis in code, comments, or documentation
- All public types must have godoc comments
- Run
go vet ./...before committing - Keep each file under 400 lines; split by responsibility
- No
panicexcept in initialization failures or truly unimplemented stubs - Eviction callbacks must not be called while holding a mutex
- All new policies must implement
Cacher[K, V]and be wrapped viaCacheWrapper - Update
PolicyTypeenum and regenerate stringer when adding new policies