Add cache optimization module with scalable dynamic content detection
Implements a comprehensive cache optimization layer for LLM providers:
- Provider-specific optimizers (Anthropic, OpenAI, Google) with distinct
caching strategies: explicit breakpoints, prefix stabilization, and
CachedContent API respectively
- Scalable dynamic content detector using three strategies:
1. Structural detection: "Label: value" patterns (language-agnostic)
2. Entropy-based detection: high-entropy strings (IDs, tokens, hashes)
3. Universal patterns: ISO 8601, UUIDs, JWTs, hex hashes
- NO hardcoded locale-specific patterns (no month names, etc.)
- Semantic caching layer with LRU eviction and TTL support
- Plugin registry for provider selection and custom optimizers
- 131 tests, real-world benchmarks showing 20-55% compression at <0.3ms
2026-01-07 14:07:49 -08:00
|
|
|
"""Tests for SemanticCache and SemanticCacheLayer."""
|
|
|
|
|
|
|
|
|
|
import time
|
2026-01-10 15:33:44 -08:00
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
Add cache optimization module with scalable dynamic content detection
Implements a comprehensive cache optimization layer for LLM providers:
- Provider-specific optimizers (Anthropic, OpenAI, Google) with distinct
caching strategies: explicit breakpoints, prefix stabilization, and
CachedContent API respectively
- Scalable dynamic content detector using three strategies:
1. Structural detection: "Label: value" patterns (language-agnostic)
2. Entropy-based detection: high-entropy strings (IDs, tokens, hashes)
3. Universal patterns: ISO 8601, UUIDs, JWTs, hex hashes
- NO hardcoded locale-specific patterns (no month names, etc.)
- Semantic caching layer with LRU eviction and TTL support
- Plugin registry for provider selection and custom optimizers
- 131 tests, real-world benchmarks showing 20-55% compression at <0.3ms
2026-01-07 14:07:49 -08:00
|
|
|
from headroom.cache import (
|
|
|
|
|
AnthropicCacheOptimizer,
|
|
|
|
|
OptimizationContext,
|
2026-01-10 15:33:44 -08:00
|
|
|
SemanticCache,
|
|
|
|
|
SemanticCacheLayer,
|
Add cache optimization module with scalable dynamic content detection
Implements a comprehensive cache optimization layer for LLM providers:
- Provider-specific optimizers (Anthropic, OpenAI, Google) with distinct
caching strategies: explicit breakpoints, prefix stabilization, and
CachedContent API respectively
- Scalable dynamic content detector using three strategies:
1. Structural detection: "Label: value" patterns (language-agnostic)
2. Entropy-based detection: high-entropy strings (IDs, tokens, hashes)
3. Universal patterns: ISO 8601, UUIDs, JWTs, hex hashes
- NO hardcoded locale-specific patterns (no month names, etc.)
- Semantic caching layer with LRU eviction and TTL support
- Plugin registry for provider selection and custom optimizers
- 131 tests, real-world benchmarks showing 20-55% compression at <0.3ms
2026-01-07 14:07:49 -08:00
|
|
|
)
|
2026-01-10 15:33:44 -08:00
|
|
|
from headroom.cache.semantic import SemanticCacheConfig
|
Add cache optimization module with scalable dynamic content detection
Implements a comprehensive cache optimization layer for LLM providers:
- Provider-specific optimizers (Anthropic, OpenAI, Google) with distinct
caching strategies: explicit breakpoints, prefix stabilization, and
CachedContent API respectively
- Scalable dynamic content detector using three strategies:
1. Structural detection: "Label: value" patterns (language-agnostic)
2. Entropy-based detection: high-entropy strings (IDs, tokens, hashes)
3. Universal patterns: ISO 8601, UUIDs, JWTs, hex hashes
- NO hardcoded locale-specific patterns (no month names, etc.)
- Semantic caching layer with LRU eviction and TTL support
- Plugin registry for provider selection and custom optimizers
- 131 tests, real-world benchmarks showing 20-55% compression at <0.3ms
2026-01-07 14:07:49 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSemanticCacheConfig:
|
|
|
|
|
"""Test SemanticCacheConfig."""
|
|
|
|
|
|
|
|
|
|
def test_default_values(self):
|
|
|
|
|
"""Test default configuration values."""
|
|
|
|
|
config = SemanticCacheConfig()
|
|
|
|
|
assert config.similarity_threshold == 0.95
|
|
|
|
|
assert config.max_entries == 1000
|
|
|
|
|
assert config.ttl_seconds == 300
|
|
|
|
|
assert config.use_exact_matching is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSemanticCache:
|
|
|
|
|
"""Test SemanticCache functionality."""
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def cache(self):
|
|
|
|
|
"""Create cache instance."""
|
|
|
|
|
config = SemanticCacheConfig(
|
|
|
|
|
max_entries=10,
|
|
|
|
|
ttl_seconds=60,
|
|
|
|
|
)
|
|
|
|
|
return SemanticCache(config)
|
|
|
|
|
|
|
|
|
|
def test_put_and_get_exact_match(self, cache):
|
|
|
|
|
"""Test storing and retrieving with exact hash matching."""
|
|
|
|
|
response = {"text": "Hello, how can I help?"}
|
|
|
|
|
cache.put("What is the weather?", response, messages_hash="hash123")
|
|
|
|
|
|
|
|
|
|
entry = cache.get("What is the weather?", messages_hash="hash123")
|
|
|
|
|
assert entry is not None
|
|
|
|
|
assert entry.response == response
|
|
|
|
|
|
|
|
|
|
def test_get_miss(self, cache):
|
|
|
|
|
"""Test cache miss."""
|
|
|
|
|
entry = cache.get("Unknown query", messages_hash="unknown")
|
|
|
|
|
assert entry is None
|
|
|
|
|
|
fix(cache/semantic): key entries by context hash, not query text (#2022)
## Description
`SemanticCache` (`headroom/cache/semantic.py`) derives each entry's key
from the **query text
only** — where `query` is just the trailing user message — and its
exact-match lookup returns
the slot without checking the stored entry's `messages_hash`:
```python
# put()
key = self._generate_key(query) # sha256(query)[:16]
self._cache[key] = entry
if messages_hash:
self._hash_index[messages_hash] = key
# get() — exact-match branch
key = self._hash_index.get(messages_hash)
if key and key in self._cache:
entry = self._cache[key]
...
return entry # never checks entry.messages_hash
```
So two requests that share a trailing user message but differ in earlier
context map to the
**same** key. The second `put` overwrites the first, and the first
request's `messages_hash`
still points at that (now overwritten) slot — so it is served the
**other conversation's**
cached response.
Trailing messages like `"continue"`, `"yes"`, `"fix it"`, `"run the
tests"` are extremely
common in agentic/coding sessions, so this collides constantly. It's
independent of the
proxy-level `_compute_key` fix (that's about what goes *into*
`messages_hash`; here the entry
is stored under a query-only key regardless of how good the hash is).
This `SemanticCache` is
the one used by the SDK client's `enable_semantic_cache` path.
Concretely:
1. `put("run the tests", A, messages_hash=HA)` → key `K = sha256("run
the tests")`; `_cache[K]=A`.
2. `put("run the tests", B, messages_hash=HB)` → same `K`; `_cache[K]`
overwritten with `B`.
3. `get("run the tests", HA)` → `_hash_index[HA]=K`, `K in _cache` →
returns **B**.
Closes: no issue filed — found while auditing the cache key derivation.
## Fix
1. Key entries by the full-context `messages_hash` when present, falling
back to the query hash
only when no hash is supplied:
```python
key = messages_hash or self._generate_key(query)
```
2. Defensively verify `entry.messages_hash == messages_hash` in the
exact-match branch of `get`,
so any residual stale mapping becomes a miss rather than wrong data.
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
## Changes Made
- `headroom/cache/semantic.py`: key `put` entries by `messages_hash`
when present; verify `entry.messages_hash` in the `get` exact-match
branch.
- `tests/test_cache/test_semantic.py`: add
`test_same_query_different_context_does_not_collide` and
`test_exact_match_verifies_messages_hash`.
## Testing
- [x] New regression tests added (`tests/test_cache/test_semantic.py`)
- [x] Linting/formatting clean — run with the CI-pinned `ruff==0.15.17`
- [ ] Full `pytest` deferred to CI (local-OOM reason below).
```text
$ uvx ruff@0.15.17 check headroom/cache/semantic.py tests/test_cache/test_semantic.py
All checks passed!
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.10, headroom from this branch.
Importing `headroom` pulls in the torch/transformers stack and a full
`pytest` gets OOM-killed on this box, so I verified the `put`/`get`
logic with a dependency-free script and left the full pytest to CI.
- Exact command / steps: stored responses A and B under the same query
`"run the tests"` with different `messages_hash`, then read each hash
back — through both the old (query-keyed) and new (hash-keyed) logic.
- Observed result: the old logic serves B's response to request A; the
new logic isolates them:
```text
OLD: A->RESPONSE_B B->RESPONSE_B
NEW: A->RESPONSE_A B->RESPONSE_B
SEMANTIC CACHE COLLISION FIX VERIFIED (OLD served B to A; NEW isolates)
```
- Not tested: the full SDK `HeadroomClient` round-trip with
`enable_semantic_cache=True` (needs the heavy stack). The fix is
confined to `SemanticCache.put`/`get` and the new tests drive them
directly. Full local `pytest` deferred to CI (OOM, per above).
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes — ran
lint + a standalone logic check; full pytest deferred to CI (local OOM,
disclosed above)
- [x] I have updated the CHANGELOG.md if applicable
## Additional Notes
- Small, contained fix — the key derivation plus a verification guard,
no new dependencies.
- @JerrettDavis tagging you — this one can serve one conversation's
cached response to another when the last message matches, so it seemed
worth surfacing. Thanks!
2026-07-11 20:41:09 +05:30
|
|
|
def test_same_query_different_context_does_not_collide(self, cache):
|
|
|
|
|
"""Two requests that share a trailing user message but differ in earlier
|
|
|
|
|
context (distinct messages_hash) must not overwrite each other. Before the
|
|
|
|
|
fix both were keyed by sha256(query), so the second clobbered the first and
|
|
|
|
|
the first's hash resolved to the second's response."""
|
|
|
|
|
cache.put("run the tests", {"text": "response A"}, messages_hash="ctxA")
|
|
|
|
|
cache.put("run the tests", {"text": "response B"}, messages_hash="ctxB")
|
|
|
|
|
|
|
|
|
|
got_a = cache.get("run the tests", messages_hash="ctxA")
|
|
|
|
|
got_b = cache.get("run the tests", messages_hash="ctxB")
|
|
|
|
|
|
|
|
|
|
assert got_a is not None and got_a.response == {"text": "response A"}
|
|
|
|
|
assert got_b is not None and got_b.response == {"text": "response B"}
|
|
|
|
|
|
|
|
|
|
def test_exact_match_verifies_messages_hash(self, cache):
|
|
|
|
|
"""A stored entry is only returned when its messages_hash matches the
|
|
|
|
|
looked-up hash — never another conversation's cached response."""
|
|
|
|
|
cache.put("continue", {"text": "A"}, messages_hash="hA")
|
|
|
|
|
# A lookup for a hash that isn't stored is a miss, not a wrong hit.
|
|
|
|
|
assert cache.get("continue", messages_hash="hB") is None
|
|
|
|
|
|
Add cache optimization module with scalable dynamic content detection
Implements a comprehensive cache optimization layer for LLM providers:
- Provider-specific optimizers (Anthropic, OpenAI, Google) with distinct
caching strategies: explicit breakpoints, prefix stabilization, and
CachedContent API respectively
- Scalable dynamic content detector using three strategies:
1. Structural detection: "Label: value" patterns (language-agnostic)
2. Entropy-based detection: high-entropy strings (IDs, tokens, hashes)
3. Universal patterns: ISO 8601, UUIDs, JWTs, hex hashes
- NO hardcoded locale-specific patterns (no month names, etc.)
- Semantic caching layer with LRU eviction and TTL support
- Plugin registry for provider selection and custom optimizers
- 131 tests, real-world benchmarks showing 20-55% compression at <0.3ms
2026-01-07 14:07:49 -08:00
|
|
|
def test_lru_eviction(self):
|
|
|
|
|
"""Test LRU eviction when at capacity."""
|
|
|
|
|
config = SemanticCacheConfig(max_entries=3)
|
|
|
|
|
cache = SemanticCache(config)
|
|
|
|
|
|
|
|
|
|
# Fill cache
|
|
|
|
|
cache.put("query1", "response1", messages_hash="h1")
|
|
|
|
|
cache.put("query2", "response2", messages_hash="h2")
|
|
|
|
|
cache.put("query3", "response3", messages_hash="h3")
|
|
|
|
|
|
|
|
|
|
# Access query1 to make it recently used
|
|
|
|
|
cache.get("query1", messages_hash="h1")
|
|
|
|
|
|
|
|
|
|
# Add new entry, should evict query2 (oldest unused)
|
|
|
|
|
cache.put("query4", "response4", messages_hash="h4")
|
|
|
|
|
|
|
|
|
|
# query1 should still be there (recently accessed)
|
|
|
|
|
assert cache.get("query1", messages_hash="h1") is not None
|
|
|
|
|
# query2 should be evicted
|
|
|
|
|
assert cache.get("query2", messages_hash="h2") is None
|
|
|
|
|
# query3 and query4 should be there
|
|
|
|
|
assert cache.get("query3", messages_hash="h3") is not None
|
|
|
|
|
assert cache.get("query4", messages_hash="h4") is not None
|
|
|
|
|
|
fix(cache/semantic): don't evict an unrelated entry on an update at capacity (#2094)
## Description
`SemanticCache.put` can evict a perfectly good, unrelated entry when it
merely updates a key that is already cached.
The method runs its at-capacity eviction loop *before* it computes the
entry's key:
```python
self._cleanup_expired()
# Evict if at capacity
while len(self._cache) >= self.config.max_entries:
self._evict_oldest()
...
key = messages_hash or self._generate_key(query)
...
self._cache[key] = entry
```
So when the same key is stored again while the cache is full (a
duplicate store, or a retried request that produces the same
`messages_hash`), the loop fires because `len == max_entries`, evicts
the LRU-oldest *distinct* entry, and only then overwrites the existing
key in place. Writing to an already-present key does not grow the map,
so nothing needed to be evicted — but an unrelated live entry is now
gone, and the next `get` for it is a false miss.
Concretely, with `max_entries=2` and keys `[h1, h2]`, re-storing `h2`
evicts `h1`, leaving `[h2]` even though only two distinct keys were ever
stored.
The sibling `CompressionCache.store_compressed` gets this right: it
deletes the existing key first, inserts, and only then trims — so
re-storing a present key never drops an unrelated entry.
## Fix
Compute the key first, then run the eviction loop only while the key is
genuinely new:
```python
key = messages_hash or self._generate_key(query)
while key not in self._cache and len(self._cache) >= self.config.max_entries:
self._evict_oldest()
```
An in-place update of an existing key no longer evicts anything; adding
a new key still trims to make room exactly as before.
Closes #
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- `headroom/cache/semantic.py`: move the cache-key computation above the
eviction loop and gate the loop on `key not in self._cache` so an
in-place update never evicts.
- `tests/test_cache/test_semantic.py`: add
`test_update_at_capacity_does_not_evict_unrelated_entry`.
- `CHANGELOG.md`: Bug Fixes entry.
## Testing
- [ ] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed
### Test Output
```text
$ uvx ruff@0.15.17 check headroom/cache/semantic.py tests/test_cache/test_semantic.py
All checks passed!
$ python -m py_compile headroom/cache/semantic.py tests/test_cache/test_semantic.py
OK
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17`. Importing
`headroom` pulls in the torch/transformers stack and a full `pytest`
gets OOM-killed on this box, so I verified the eviction logic with a
dependency-free script that replicates the `OrderedDict` +
`_evict_oldest` (popitem last=False) behavior for the old vs new loop,
and left the full pytest to CI.
- Exact command / steps: with `max_entries=2`, store `h1` then `h2`,
then re-store the already-present `h2`, under both the old loop (evict
before key dedup) and the new loop (evict only when key is new).
- Observed result: old loop leaves `['h2']` and `get(h1)` returns `None`
(h1 wrongly evicted); new loop leaves `['h1', 'h2']` with `get(h1)`
intact and `h2` updated. The regression test asserts h1 survives and h2
reflects the update.
- Not tested: a live embedding-backed cache round-trip; full local
`pytest` deferred to CI (OOM, per above).
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable
## Additional Notes
The "unit tests pass locally" and "type checking" boxes are unchecked
because the full suite imports the ML stack, which I can't run in this
environment; the change is a localized reordering of two existing
statements plus a loop guard, verified by the standalone proof and the
new regression test for CI. This is a different defect from the earlier
messages-hash keying fix — that one was about which slot a request maps
to; this one is about eviction dropping a live entry on an in-place
update.
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-13 20:20:23 +05:30
|
|
|
def test_update_at_capacity_does_not_evict_unrelated_entry(self):
|
|
|
|
|
"""Re-storing an existing key at capacity must not drop another entry.
|
|
|
|
|
|
|
|
|
|
The eviction loop used to run before the cache key was computed, so
|
|
|
|
|
overwriting a key that was already present (a retried/duplicate store)
|
|
|
|
|
still evicted the LRU-oldest distinct entry even though the update grows
|
|
|
|
|
nothing. That silently dropped a live entry and turned a later lookup for
|
|
|
|
|
it into a false miss.
|
|
|
|
|
"""
|
|
|
|
|
config = SemanticCacheConfig(max_entries=2)
|
|
|
|
|
cache = SemanticCache(config)
|
|
|
|
|
|
|
|
|
|
cache.put("query1", "response1", messages_hash="h1")
|
|
|
|
|
cache.put("query2", "response2", messages_hash="h2")
|
|
|
|
|
|
|
|
|
|
# Re-store the already-present h2 (e.g. a duplicate/retried request).
|
|
|
|
|
cache.put("query2", "response2b", messages_hash="h2")
|
|
|
|
|
|
|
|
|
|
# h1 must still be there — updating h2 must not evict it.
|
|
|
|
|
got1 = cache.get("query1", messages_hash="h1")
|
|
|
|
|
assert got1 is not None and got1.response == "response1"
|
|
|
|
|
# h2 reflects the update.
|
|
|
|
|
got2 = cache.get("query2", messages_hash="h2")
|
|
|
|
|
assert got2 is not None and got2.response == "response2b"
|
|
|
|
|
|
Add cache optimization module with scalable dynamic content detection
Implements a comprehensive cache optimization layer for LLM providers:
- Provider-specific optimizers (Anthropic, OpenAI, Google) with distinct
caching strategies: explicit breakpoints, prefix stabilization, and
CachedContent API respectively
- Scalable dynamic content detector using three strategies:
1. Structural detection: "Label: value" patterns (language-agnostic)
2. Entropy-based detection: high-entropy strings (IDs, tokens, hashes)
3. Universal patterns: ISO 8601, UUIDs, JWTs, hex hashes
- NO hardcoded locale-specific patterns (no month names, etc.)
- Semantic caching layer with LRU eviction and TTL support
- Plugin registry for provider selection and custom optimizers
- 131 tests, real-world benchmarks showing 20-55% compression at <0.3ms
2026-01-07 14:07:49 -08:00
|
|
|
def test_ttl_expiration(self):
|
|
|
|
|
"""Test TTL expiration."""
|
|
|
|
|
config = SemanticCacheConfig(ttl_seconds=1)
|
|
|
|
|
cache = SemanticCache(config)
|
|
|
|
|
|
|
|
|
|
cache.put("expiring query", "response", messages_hash="exp1")
|
|
|
|
|
|
|
|
|
|
# Should be available immediately
|
|
|
|
|
assert cache.get("expiring query", messages_hash="exp1") is not None
|
|
|
|
|
|
|
|
|
|
# Wait for TTL
|
|
|
|
|
time.sleep(1.1)
|
|
|
|
|
|
|
|
|
|
# Should be expired
|
|
|
|
|
assert cache.get("expiring query", messages_hash="exp1") is None
|
|
|
|
|
|
|
|
|
|
def test_invalidate(self, cache):
|
|
|
|
|
"""Test invalidating an entry."""
|
|
|
|
|
key = cache.put("query", "response", messages_hash="inv1")
|
|
|
|
|
|
|
|
|
|
assert cache.get("query", messages_hash="inv1") is not None
|
|
|
|
|
|
|
|
|
|
cache.invalidate(key)
|
|
|
|
|
|
|
|
|
|
assert cache.get("query", messages_hash="inv1") is None
|
|
|
|
|
|
|
|
|
|
def test_clear(self, cache):
|
|
|
|
|
"""Test clearing cache."""
|
|
|
|
|
cache.put("query1", "response1", messages_hash="c1")
|
|
|
|
|
cache.put("query2", "response2", messages_hash="c2")
|
|
|
|
|
|
|
|
|
|
cache.clear()
|
|
|
|
|
|
|
|
|
|
stats = cache.get_stats()
|
|
|
|
|
assert stats["entries"] == 0
|
|
|
|
|
|
|
|
|
|
def test_stats(self, cache):
|
|
|
|
|
"""Test statistics."""
|
|
|
|
|
cache.put("query", "response", messages_hash="s1")
|
|
|
|
|
cache.get("query", messages_hash="s1") # hit
|
|
|
|
|
cache.get("unknown", messages_hash="unknown") # miss
|
|
|
|
|
|
|
|
|
|
stats = cache.get_stats()
|
|
|
|
|
|
|
|
|
|
assert stats["entries"] == 1
|
|
|
|
|
assert stats["hits"] == 1
|
|
|
|
|
assert stats["misses"] == 1
|
|
|
|
|
assert stats["hit_rate"] == 0.5
|
|
|
|
|
|
|
|
|
|
def test_access_count(self, cache):
|
|
|
|
|
"""Test that access count is tracked."""
|
|
|
|
|
cache.put("query", "response", messages_hash="ac1")
|
|
|
|
|
|
|
|
|
|
# Access multiple times
|
|
|
|
|
for _ in range(5):
|
|
|
|
|
entry = cache.get("query", messages_hash="ac1")
|
|
|
|
|
|
|
|
|
|
# Initial count is 1, plus 5 accesses = 6
|
|
|
|
|
assert entry.access_count == 6
|
|
|
|
|
|
|
|
|
|
def test_semantic_similarity_with_embedding_fn(self):
|
|
|
|
|
"""Test semantic similarity with custom embedding function."""
|
|
|
|
|
|
|
|
|
|
def mock_embedding(text: str) -> list[float]:
|
|
|
|
|
# Simple mock: return consistent embedding for similar queries
|
|
|
|
|
if "weather" in text.lower():
|
|
|
|
|
return [1.0, 0.0, 0.0]
|
|
|
|
|
elif "time" in text.lower():
|
|
|
|
|
return [0.0, 1.0, 0.0]
|
|
|
|
|
else:
|
|
|
|
|
return [0.0, 0.0, 1.0]
|
|
|
|
|
|
|
|
|
|
config = SemanticCacheConfig(similarity_threshold=0.9)
|
|
|
|
|
cache = SemanticCache(config, embedding_fn=mock_embedding)
|
|
|
|
|
|
|
|
|
|
# Store a weather query
|
|
|
|
|
cache.put("What is the weather today?", "It's sunny", messages_hash="w1")
|
|
|
|
|
|
|
|
|
|
# Similar weather query should hit
|
|
|
|
|
entry = cache.get("How is the weather?")
|
|
|
|
|
assert entry is not None
|
|
|
|
|
assert entry.response == "It's sunny"
|
|
|
|
|
|
|
|
|
|
# Different query should miss
|
|
|
|
|
entry = cache.get("What time is it?")
|
|
|
|
|
assert entry is None
|
|
|
|
|
|
fix(cache/semantic): don't semantic-match an empty query across contexts (#3226)
## Description
`SemanticCache.get()` matches on the **embedding of the last user
message** whenever an `embedding_fn` is wired. That query is empty
(`""`) for the overwhelming majority of agent/tool turns — a
`tool_result` continuation carries no text block, so
`SemanticCacheLayer._extract_query` returns `""`. A real sentence
embedder maps `""` to a fixed **non-zero** vector, so every empty-query
turn is ~identical to every other in embedding space. The exact
`messages_hash` guard (correctly chosen so `"continue"`/`"yes"` turns in
different contexts don't collide) is then bypassed by the semantic path:
an empty-query request misses on its unique hash, falls through to
embedding matching, and hits a **different conversation's** stored
response.
Reproduction (realistic embedder, non-zero for `""`):
```python
c = SemanticCache(embedding_fn=embed)
c.put(query="", response={"answer": "A"}, messages_hash="ctxA") # conversation A
c.get(query="", messages_hash="ctxB") # conversation B, different context
# -> returned A's response (cross-context false hit)
```
Measured on 330 real Claude Code transcripts (28,441 requests): **95.7%
have an empty extracted query**, so this is the dominant case, not a
corner case. The exact-hash path is unaffected; only the
embedding-similarity path is.
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- `headroom/cache/semantic.py`:
- `get()`: gate the semantic-similarity branch on `query.strip()` — an
empty/blank query can only ever hit via its exact `messages_hash`
(context-complete), never via embedding similarity.
- `put()`: store no embedding for an empty/blank query, so such an entry
is skipped by `_find_similar` (which ignores entries with no embedding)
and can never be a match target.
- `tests/test_cache/test_semantic.py`: added
`test_empty_query_never_semantic_matches` (cross-context empty-query
miss, exact-hash still hits, whitespace treated as empty).
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
### Test Output
```text
tests/test_cache/test_semantic.py -> 22 passed in 2.39s
uvx ruff@0.16.2 check headroom/cache/semantic.py tests/test_cache/test_semantic.py -> All checks passed!
uvx mypy@1.20.2 headroom/cache/semantic.py -> Success: no issues found in 1 source file
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.12.11, project venv, pytest 9.1.1,
ruff 0.16.2 and mypy 1.20.2 via uvx.
- Exact command / steps: before the fix, two different-context
empty-query requests (`ctxA` then `ctxB`) returned `ctxA`'s response via
the embedding path. After the fix, the second returns `None`, while
`ctxA`'s own exact-hash lookup still returns its response, and a
legitimate non-empty semantic hit (`"What is the weather today?"` ->
`"How is the weather?"`) still works.
- Observed result: empty/blank queries no longer semantic-match across
contexts; exact-hash and non-empty semantic matching are unchanged.
- Not tested: no live embedder model wired (the current client wires
none — the embedding path is exercised with an injected `embedding_fn`,
which is the documented usage).
## Runtime Rollout Safety
- Rollout-managed feature(s): none. `SemanticCache` is an SDK-side cache
(`headroom.cache`), not a rollout-channel-gated runtime feature;
semantic matching only runs when a caller injects an `embedding_fn`.
- Minimum rollout channel: N/A.
- Stable/default behavior changed: no. Exact-hash matching and non-empty
semantic matching are unchanged; only empty/blank-query semantic
matching (a false-hit source) is removed.
- Kill switch / disable path: N/A.
- Unsafe override required: no.
- Qualification impact: none; correctness-only.
- Rollback path: revert this PR.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation (N/A:
internal behavior)
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md`
2026-08-24 00:21:33 +05:30
|
|
|
def test_empty_query_never_semantic_matches(self):
|
|
|
|
|
"""An empty extracted query must not trigger cross-context false hits.
|
|
|
|
|
|
|
|
|
|
The query is the last user message; in agent/tool traffic most turns are
|
|
|
|
|
tool_result continuations whose extracted query is "". A real embedder
|
|
|
|
|
maps "" to a fixed non-zero vector, so without a guard every empty-query
|
|
|
|
|
turn would be ~identical to every other and serve one conversation's
|
|
|
|
|
response to an unrelated one. An empty query may only ever hit via the
|
|
|
|
|
exact messages_hash (which is context-complete).
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def const_embedding(text: str) -> list[float]:
|
|
|
|
|
# Realistic: a non-zero, identical vector for every input (incl. "").
|
|
|
|
|
return [0.5, 0.5, 0.5]
|
|
|
|
|
|
|
|
|
|
config = SemanticCacheConfig(similarity_threshold=0.9)
|
|
|
|
|
cache = SemanticCache(config, embedding_fn=const_embedding)
|
|
|
|
|
|
|
|
|
|
# Conversation A: an empty-query turn (unique full-context hash).
|
|
|
|
|
cache.put("", "response-A", messages_hash="ctxA")
|
|
|
|
|
|
|
|
|
|
# Conversation B: a different empty-query turn — must NOT get A's answer.
|
|
|
|
|
assert cache.get("", messages_hash="ctxB") is None
|
|
|
|
|
# Its own exact hash still works.
|
|
|
|
|
assert cache.get("", messages_hash="ctxA").response == "response-A"
|
|
|
|
|
|
|
|
|
|
# A whitespace-only query is treated the same as empty.
|
|
|
|
|
cache.put(" \n\t", "response-C", messages_hash="ctxC")
|
|
|
|
|
assert cache.get(" ", messages_hash="ctxD") is None
|
|
|
|
|
|
Add cache optimization module with scalable dynamic content detection
Implements a comprehensive cache optimization layer for LLM providers:
- Provider-specific optimizers (Anthropic, OpenAI, Google) with distinct
caching strategies: explicit breakpoints, prefix stabilization, and
CachedContent API respectively
- Scalable dynamic content detector using three strategies:
1. Structural detection: "Label: value" patterns (language-agnostic)
2. Entropy-based detection: high-entropy strings (IDs, tokens, hashes)
3. Universal patterns: ISO 8601, UUIDs, JWTs, hex hashes
- NO hardcoded locale-specific patterns (no month names, etc.)
- Semantic caching layer with LRU eviction and TTL support
- Plugin registry for provider selection and custom optimizers
- 131 tests, real-world benchmarks showing 20-55% compression at <0.3ms
2026-01-07 14:07:49 -08:00
|
|
|
|
|
|
|
|
class TestSemanticCacheLayer:
|
|
|
|
|
"""Test SemanticCacheLayer functionality."""
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def layer(self):
|
|
|
|
|
"""Create cache layer with Anthropic optimizer."""
|
|
|
|
|
optimizer = AnthropicCacheOptimizer()
|
|
|
|
|
return SemanticCacheLayer(
|
|
|
|
|
optimizer,
|
|
|
|
|
similarity_threshold=0.95,
|
|
|
|
|
max_entries=100,
|
|
|
|
|
ttl_seconds=60,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def context(self):
|
|
|
|
|
"""Create optimization context."""
|
|
|
|
|
return OptimizationContext(
|
|
|
|
|
provider="anthropic",
|
|
|
|
|
model="claude-3-opus",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_process_no_cache_hit(self, layer, context):
|
|
|
|
|
"""Test processing with no cache hit."""
|
|
|
|
|
messages = [
|
|
|
|
|
{"role": "system", "content": "You are helpful."},
|
|
|
|
|
{"role": "user", "content": "Hello!"},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
result = layer.process(messages, context)
|
|
|
|
|
|
|
|
|
|
assert result.semantic_cache_hit is False
|
|
|
|
|
assert result.cached_response is None
|
|
|
|
|
|
|
|
|
|
def test_process_with_cache_hit(self, layer, context):
|
|
|
|
|
"""Test processing with cache hit."""
|
|
|
|
|
messages = [
|
|
|
|
|
{"role": "system", "content": "You are helpful."},
|
|
|
|
|
{"role": "user", "content": "What is 2+2?"},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
# First, store a response
|
|
|
|
|
layer.store_response(messages, {"text": "4"}, context)
|
|
|
|
|
|
|
|
|
|
# Now process same messages
|
|
|
|
|
result = layer.process(messages, context)
|
|
|
|
|
|
|
|
|
|
assert result.semantic_cache_hit is True
|
|
|
|
|
assert result.cached_response == {"text": "4"}
|
|
|
|
|
|
|
|
|
|
def test_store_response(self, layer, context):
|
|
|
|
|
"""Test storing a response."""
|
|
|
|
|
messages = [
|
|
|
|
|
{"role": "system", "content": "You are helpful."},
|
|
|
|
|
{"role": "user", "content": "Tell me a joke"},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
key = layer.store_response(messages, {"text": "Why did..."}, context)
|
|
|
|
|
|
|
|
|
|
assert key is not None
|
|
|
|
|
assert len(key) > 0
|
|
|
|
|
|
|
|
|
|
def test_get_stats(self, layer, context):
|
|
|
|
|
"""Test getting statistics."""
|
|
|
|
|
messages = [
|
|
|
|
|
{"role": "system", "content": "You are helpful."},
|
|
|
|
|
{"role": "user", "content": "Hello"},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
layer.process(messages, context)
|
|
|
|
|
stats = layer.get_stats()
|
|
|
|
|
|
|
|
|
|
assert "semantic_cache" in stats
|
|
|
|
|
assert "provider_optimizer" in stats
|
|
|
|
|
assert stats["provider_optimizer"] == "anthropic-cache-optimizer"
|
|
|
|
|
|
|
|
|
|
def test_query_extraction(self, layer, context):
|
|
|
|
|
"""Test query extraction from messages."""
|
|
|
|
|
messages = [
|
|
|
|
|
{"role": "system", "content": "System"},
|
|
|
|
|
{"role": "user", "content": "First question"},
|
|
|
|
|
{"role": "assistant", "content": "Answer"},
|
|
|
|
|
{"role": "user", "content": "Second question"},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
# Store response
|
|
|
|
|
layer.store_response(messages, {"text": "Response"}, context)
|
|
|
|
|
|
|
|
|
|
# The query should be the last user message
|
|
|
|
|
result = layer.process(messages, context)
|
|
|
|
|
assert result.semantic_cache_hit is True
|
|
|
|
|
|
|
|
|
|
def test_query_from_context(self, layer):
|
|
|
|
|
"""Test using query from context."""
|
|
|
|
|
messages = [
|
|
|
|
|
{"role": "user", "content": "Some message"},
|
|
|
|
|
]
|
|
|
|
|
context = OptimizationContext(
|
|
|
|
|
query="Specific query for caching",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
layer.store_response(messages, {"text": "Response"}, context)
|
|
|
|
|
result = layer.process(messages, context)
|
|
|
|
|
|
|
|
|
|
assert result.semantic_cache_hit is True
|
|
|
|
|
|
|
|
|
|
def test_provider_optimizer_fallback(self, layer, context):
|
|
|
|
|
"""Test that provider optimizer is used on cache miss."""
|
|
|
|
|
messages = [
|
|
|
|
|
{"role": "system", "content": "You are helpful. " * 500},
|
|
|
|
|
{"role": "user", "content": "New uncached question"},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
result = layer.process(messages, context)
|
|
|
|
|
|
|
|
|
|
# Should have used provider optimizer
|
|
|
|
|
assert result.semantic_cache_hit is False
|
|
|
|
|
# Provider optimizer should have processed
|
|
|
|
|
assert result.metrics.stable_prefix_hash != ""
|
|
|
|
|
|
|
|
|
|
def test_content_block_query_extraction(self, layer, context):
|
|
|
|
|
"""Test query extraction from content block format."""
|
|
|
|
|
messages = [
|
|
|
|
|
{"role": "system", "content": "System"},
|
|
|
|
|
{
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": [{"type": "text", "text": "Block format question"}],
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
layer.store_response(messages, {"text": "Response"}, context)
|
|
|
|
|
result = layer.process(messages, context)
|
|
|
|
|
|
|
|
|
|
assert result.semantic_cache_hit is True
|