headroom/tests/test_cache/test_semantic.py

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

376 lines
14 KiB
Python
Raw Normal View History

"""Tests for SemanticCache and SemanticCacheLayer."""
import time
import pytest
from headroom.cache import (
AnthropicCacheOptimizer,
OptimizationContext,
SemanticCache,
SemanticCacheLayer,
)
from headroom.cache.semantic import SemanticCacheConfig
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
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"
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
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