chopratejas Claude Opus 4.6 (1M context) commited on
Commit
29928b8
·
1 Parent(s): 830f939

feat: add CompressionCache with LRU eviction for token headroom mode

Browse files

Co-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