Spaces:
Build error
Build error
Commit ·
29928b8
1
Parent(s): 830f939
feat: add CompressionCache with LRU eviction for token headroom mode
Browse filesCo-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
headroom/cache/compression_cache.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Content-addressed compression cache with LRU eviction.
|
| 2 |
+
|
| 3 |
+
Used in "token headroom mode" to avoid re-compressing messages across turns.
|
| 4 |
+
Maps original content hashes to their compressed versions.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import hashlib
|
| 10 |
+
import json
|
| 11 |
+
from collections import OrderedDict
|
| 12 |
+
from dataclasses import dataclass
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass
|
| 16 |
+
class _CacheEntry:
|
| 17 |
+
"""Internal cache entry storing compressed text and metadata."""
|
| 18 |
+
|
| 19 |
+
compressed: str
|
| 20 |
+
tokens_saved: int
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class CompressionCache:
|
| 24 |
+
"""Content-addressed cache mapping content hashes to compressed versions.
|
| 25 |
+
|
| 26 |
+
Uses an OrderedDict for O(1) LRU eviction. Entries are evicted oldest-first
|
| 27 |
+
when the cache exceeds max_entries.
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
def __init__(self, max_entries: int = 10000) -> None:
|
| 31 |
+
self.max_entries = max_entries
|
| 32 |
+
self._cache: OrderedDict[str, _CacheEntry] = OrderedDict()
|
| 33 |
+
self._hits: int = 0
|
| 34 |
+
self._misses: int = 0
|
| 35 |
+
self._total_tokens_saved: int = 0
|
| 36 |
+
|
| 37 |
+
def get_compressed(self, hash: str) -> str | None:
|
| 38 |
+
"""Retrieve compressed content by hash, refreshing LRU position on hit."""
|
| 39 |
+
entry = self._cache.get(hash)
|
| 40 |
+
if entry is None:
|
| 41 |
+
self._misses += 1
|
| 42 |
+
return None
|
| 43 |
+
self._hits += 1
|
| 44 |
+
self._cache.move_to_end(hash)
|
| 45 |
+
return entry.compressed
|
| 46 |
+
|
| 47 |
+
def store_compressed(self, hash: str, compressed: str, tokens_saved: int) -> None:
|
| 48 |
+
"""Store a compressed version keyed by content hash.
|
| 49 |
+
|
| 50 |
+
If the hash already exists, the entry is overwritten and moved to the
|
| 51 |
+
end (most recently used). When the cache exceeds max_entries, the oldest
|
| 52 |
+
entry is evicted.
|
| 53 |
+
"""
|
| 54 |
+
if hash in self._cache:
|
| 55 |
+
old_entry = self._cache[hash]
|
| 56 |
+
self._total_tokens_saved -= old_entry.tokens_saved
|
| 57 |
+
del self._cache[hash]
|
| 58 |
+
|
| 59 |
+
self._cache[hash] = _CacheEntry(compressed=compressed, tokens_saved=tokens_saved)
|
| 60 |
+
self._total_tokens_saved += tokens_saved
|
| 61 |
+
|
| 62 |
+
while len(self._cache) > self.max_entries:
|
| 63 |
+
_, evicted = self._cache.popitem(last=False)
|
| 64 |
+
self._total_tokens_saved -= evicted.tokens_saved
|
| 65 |
+
|
| 66 |
+
def get_stats(self) -> dict:
|
| 67 |
+
"""Return cache statistics."""
|
| 68 |
+
return {
|
| 69 |
+
"entries": len(self._cache),
|
| 70 |
+
"hits": self._hits,
|
| 71 |
+
"misses": self._misses,
|
| 72 |
+
"tokens_saved": self._total_tokens_saved,
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
@staticmethod
|
| 76 |
+
def content_hash(content: str | list) -> str:
|
| 77 |
+
"""Compute a truncated SHA-256 hash for string or list content.
|
| 78 |
+
|
| 79 |
+
For list content (Anthropic-format messages with type/text/content fields),
|
| 80 |
+
the list is JSON-serialized with sorted keys for deterministic hashing.
|
| 81 |
+
"""
|
| 82 |
+
if isinstance(content, list):
|
| 83 |
+
raw = json.dumps(content, sort_keys=True, ensure_ascii=False)
|
| 84 |
+
else:
|
| 85 |
+
raw = content
|
| 86 |
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
|
tests/test_compression_cache.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for CompressionCache with LRU eviction."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from headroom.cache.compression_cache import CompressionCache
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@pytest.fixture
|
| 11 |
+
def cache() -> CompressionCache:
|
| 12 |
+
return CompressionCache()
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@pytest.fixture
|
| 16 |
+
def small_cache() -> CompressionCache:
|
| 17 |
+
return CompressionCache(max_entries=3)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class TestCompressionCache:
|
| 21 |
+
def test_cache_miss_returns_none(self, cache: CompressionCache) -> None:
|
| 22 |
+
h = CompressionCache.content_hash("some content")
|
| 23 |
+
assert cache.get_compressed(h) is None
|
| 24 |
+
|
| 25 |
+
def test_store_and_retrieve(self, cache: CompressionCache) -> None:
|
| 26 |
+
content = "hello world this is a long message"
|
| 27 |
+
h = CompressionCache.content_hash(content)
|
| 28 |
+
cache.store_compressed(h, "hello world...compressed", tokens_saved=15)
|
| 29 |
+
assert cache.get_compressed(h) == "hello world...compressed"
|
| 30 |
+
|
| 31 |
+
def test_different_content_different_hash(self) -> None:
|
| 32 |
+
h1 = CompressionCache.content_hash("content A")
|
| 33 |
+
h2 = CompressionCache.content_hash("content B")
|
| 34 |
+
assert h1 != h2
|
| 35 |
+
|
| 36 |
+
def test_overwrite_same_hash(self, cache: CompressionCache) -> None:
|
| 37 |
+
h = CompressionCache.content_hash("some content")
|
| 38 |
+
cache.store_compressed(h, "v1", tokens_saved=10)
|
| 39 |
+
cache.store_compressed(h, "v2", tokens_saved=20)
|
| 40 |
+
assert cache.get_compressed(h) == "v2"
|
| 41 |
+
|
| 42 |
+
def test_stats_tracking(self, cache: CompressionCache) -> None:
|
| 43 |
+
h = CompressionCache.content_hash("content")
|
| 44 |
+
cache.store_compressed(h, "compressed", tokens_saved=5)
|
| 45 |
+
|
| 46 |
+
# One hit
|
| 47 |
+
cache.get_compressed(h)
|
| 48 |
+
# One miss
|
| 49 |
+
cache.get_compressed("nonexistent")
|
| 50 |
+
|
| 51 |
+
stats = cache.get_stats()
|
| 52 |
+
assert stats["hits"] == 1
|
| 53 |
+
assert stats["misses"] == 1
|
| 54 |
+
assert stats["entries"] == 1
|
| 55 |
+
assert stats["tokens_saved"] == 5
|
| 56 |
+
|
| 57 |
+
def test_eviction_at_max_entries(self, small_cache: CompressionCache) -> None:
|
| 58 |
+
h1 = CompressionCache.content_hash("a")
|
| 59 |
+
h2 = CompressionCache.content_hash("b")
|
| 60 |
+
h3 = CompressionCache.content_hash("c")
|
| 61 |
+
h4 = CompressionCache.content_hash("d")
|
| 62 |
+
|
| 63 |
+
small_cache.store_compressed(h1, "ca", tokens_saved=1)
|
| 64 |
+
small_cache.store_compressed(h2, "cb", tokens_saved=1)
|
| 65 |
+
small_cache.store_compressed(h3, "cc", tokens_saved=1)
|
| 66 |
+
|
| 67 |
+
# Adding a 4th should evict the oldest (h1)
|
| 68 |
+
small_cache.store_compressed(h4, "cd", tokens_saved=1)
|
| 69 |
+
|
| 70 |
+
assert small_cache.get_compressed(h1) is None
|
| 71 |
+
assert small_cache.get_compressed(h2) == "cb"
|
| 72 |
+
assert small_cache.get_compressed(h4) == "cd"
|
| 73 |
+
|
| 74 |
+
def test_access_refreshes_lru(self, small_cache: CompressionCache) -> None:
|
| 75 |
+
h1 = CompressionCache.content_hash("a")
|
| 76 |
+
h2 = CompressionCache.content_hash("b")
|
| 77 |
+
h3 = CompressionCache.content_hash("c")
|
| 78 |
+
h4 = CompressionCache.content_hash("d")
|
| 79 |
+
|
| 80 |
+
small_cache.store_compressed(h1, "ca", tokens_saved=1)
|
| 81 |
+
small_cache.store_compressed(h2, "cb", tokens_saved=1)
|
| 82 |
+
small_cache.store_compressed(h3, "cc", tokens_saved=1)
|
| 83 |
+
|
| 84 |
+
# Access h1 to refresh it
|
| 85 |
+
small_cache.get_compressed(h1)
|
| 86 |
+
|
| 87 |
+
# Adding h4 should evict h2 (oldest untouched), not h1
|
| 88 |
+
small_cache.store_compressed(h4, "cd", tokens_saved=1)
|
| 89 |
+
|
| 90 |
+
assert small_cache.get_compressed(h1) == "ca"
|
| 91 |
+
assert small_cache.get_compressed(h2) is None
|
| 92 |
+
assert small_cache.get_compressed(h4) == "cd"
|
| 93 |
+
|
| 94 |
+
def test_content_hash_list_content(self) -> None:
|
| 95 |
+
"""content_hash handles Anthropic-format list content."""
|
| 96 |
+
list_content = [
|
| 97 |
+
{"type": "text", "text": "hello"},
|
| 98 |
+
{"type": "text", "text": "world"},
|
| 99 |
+
]
|
| 100 |
+
h = CompressionCache.content_hash(list_content)
|
| 101 |
+
assert isinstance(h, str)
|
| 102 |
+
assert len(h) == 16
|
| 103 |
+
|
| 104 |
+
# Same content produces same hash
|
| 105 |
+
assert CompressionCache.content_hash(list_content) == h
|
| 106 |
+
|
| 107 |
+
def test_content_hash_string_length(self) -> None:
|
| 108 |
+
h = CompressionCache.content_hash("test")
|
| 109 |
+
assert len(h) == 16
|