0ef5fcb1c5
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 0s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 0s
Dev Containers / validate-worktree (push) Failing after 0s
CI / changes (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Deploy Documentation / deploy (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 1s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
OpenCode Plugin / typecheck + build + test (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 1s
Init E2E / docker-init-e2e (push) Failing after 4s
Merge Conflicts / merge-conflicts (push) Failing after 4s
CI / lint (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / build (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / promote-latest (push) Has been cancelled
Init Native E2E / init-native (macos-latest, claude) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, codex) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, copilot) (push) Has been cancelled
Install Native E2E / install-native (macos-latest) (push) Has been cancelled
Wrap Native E2E / wrap-native (macos-latest) (push) Has been cancelled
321 lines
11 KiB
Python
321 lines
11 KiB
Python
"""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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
|
|
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
|