CoolFace
Apppublic

skhavin/proactive-cache

sourceHugging Faceotherupdated 4mo agoView on Hugging Face
1likes
test_proactive_cache.py161 linesDownload Raw Back to tests
1"""Unit tests for proactive_cache."""2 3import pytest4import numpy as np5import torch6 7from proactive_cache.eviction import score_tokens, select_indices, prune_kv_cache, evict8from proactive_cache.prototypes import build_prototypes, save_prototypes, load_prototypes9from proactive_cache.utils import to_tuple_kv, to_dynamic_cache10 11 12# ── Fixtures ──────────────────────────────────────────────────────────────────13 14def make_dummy_patterns(num_docs=5, num_layers=2, num_heads=4, seq_len=64):15    """Create synthetic attention patterns for testing."""16    patterns = []17    for _ in range(num_docs):18        doc = {}19        for layer in range(num_layers):20            for head in range(num_heads):21                arr = np.random.rand(seq_len).astype(np.float32)22                arr /= arr.sum()23                doc[(layer, head)] = arr24        patterns.append(doc)25    return patterns26 27 28def make_dummy_kv_cache(num_layers=2, num_heads=4, seq_len=64, head_dim=32, device="cpu"):29    """Create a synthetic KV cache tuple."""30    return tuple(31        (torch.randn(1, num_heads, seq_len, head_dim, device=device),32         torch.randn(1, num_heads, seq_len, head_dim, device=device))33        for _ in range(num_layers)34    )35 36 37# ── Eviction tests ────────────────────────────────────────────────────────────38 39class TestScoreTokens:40    def test_returns_correct_shape(self):41        scores = score_tokens(None, seq_len=128, budget=64)42        assert scores.shape == (128,)43 44    def test_token_zero_has_highest_score(self):45        scores = score_tokens(None, seq_len=128, budget=64)46        # Sink boost means position 0 is always kept47        top_k = np.argsort(scores)[-64:]48        assert 0 in top_k, "Token 0 (attention sink) must always be selected"49 50    def test_recency_tokens_kept(self):51        seq_len, budget = 128, 6452        scores = score_tokens(None, seq_len=seq_len, budget=budget)53        top_k = np.argsort(scores)[-budget:]54        # Last few tokens should be in top-k55        assert (seq_len - 1) in top_k, "Most recent token must always be kept"56 57    def test_with_prototypes(self):58        patterns = make_dummy_patterns(seq_len=64)59        protos = build_prototypes(patterns, n_clusters=2, max_seq_len=64)60        scores = score_tokens(protos, seq_len=64, budget=32)61        assert scores.shape == (64,)62        assert np.all(np.isfinite(scores)), "Scores must be finite"63 64    def test_budget_proportional_recency(self):65        # Larger budget → larger recency window (proportional)66        s128 = score_tokens(None, seq_len=512, budget=128)67        s256 = score_tokens(None, seq_len=512, budget=256)68        # More positions should be elevated in s25669        # (just check both run without error)70        assert s128.shape == s256.shape == (512,)71 72 73class TestSelectIndices:74    def test_returns_sorted(self):75        scores = np.random.rand(100)76        idx = select_indices(scores, budget=20)77        assert idx == sorted(idx), "Indices must be in ascending order"78 79    def test_correct_count(self):80        scores = np.random.rand(100)81        idx = select_indices(scores, budget=30)82        assert len(idx) == 3083 84    def test_budget_larger_than_seq(self):85        scores = np.random.rand(10)86        idx = select_indices(scores, budget=50)87        assert len(idx) == 10  # clipped to seq_len88 89 90class TestPruneKVCache:91    def test_prunes_to_budget(self):92        kv = make_dummy_kv_cache(num_layers=3, num_heads=4, seq_len=128)93        indices = list(range(0, 64, 2))  # 32 indices94        pruned = prune_kv_cache(kv, indices, device=torch.device("cpu"))95        pruned_tuple = to_tuple_kv(pruned)96        assert pruned_tuple[0][0].shape[2] == 32, "Pruned KV must have budget tokens"97 98    def test_all_layers_pruned(self):99        num_layers = 4100        kv = make_dummy_kv_cache(num_layers=num_layers, seq_len=100)101        indices = list(range(50))102        pruned_tuple = to_tuple_kv(prune_kv_cache(kv, indices, torch.device("cpu")))103        assert len(pruned_tuple) == num_layers104 105    def test_no_prune_when_under_budget(self):106        kv = make_dummy_kv_cache(seq_len=32)107        result = evict(kv, budget=64, prototypes=None, seq_len=32, device=torch.device("cpu"))108        # Should return unchanged (seq_len <= budget)109        assert to_tuple_kv(result)[0][0].shape[2] == 32110 111 112# ── Prototype tests ───────────────────────────────────────────────────────────113 114class TestPrototypes:115    def test_build_returns_dict(self):116        patterns = make_dummy_patterns()117        protos = build_prototypes(patterns, n_clusters=2, max_seq_len=64)118        assert isinstance(protos, dict)119        assert len(protos) > 0120 121    def test_centroid_shapes(self):122        patterns = make_dummy_patterns(num_layers=2, num_heads=4, seq_len=64)123        protos = build_prototypes(patterns, n_clusters=3, max_seq_len=64)124        for key, val in protos.items():125            centroids = val["centroids"]126            assert centroids.shape == (3, 64), f"Wrong centroid shape: {centroids.shape}"127 128    def test_save_load_roundtrip(self, tmp_path):129        patterns = make_dummy_patterns()130        protos = build_prototypes(patterns, n_clusters=2, max_seq_len=64)131        path = str(tmp_path / "test_protos.pkl")132        save_prototypes(protos, path)133        loaded = load_prototypes(path)134        assert set(loaded.keys()) == set(protos.keys())135 136    def test_load_missing_raises(self, tmp_path):137        with pytest.raises(FileNotFoundError):138            load_prototypes(str(tmp_path / "does_not_exist.pkl"))139 140    def test_empty_patterns_raises(self):141        with pytest.raises(ValueError):142            build_prototypes([], n_clusters=2)143 144 145# ── Utils tests ───────────────────────────────────────────────────────────────146 147class TestUtils:148    def test_to_tuple_kv_from_tuple(self):149        kv = make_dummy_kv_cache(num_layers=2)150        result = to_tuple_kv(kv)151        assert len(result) == 2152        assert isinstance(result[0], tuple)153 154    def test_to_dynamic_cache_roundtrip(self):155        kv = make_dummy_kv_cache(num_layers=2, seq_len=32)156        kv_tuple = to_tuple_kv(kv)157        dynamic = to_dynamic_cache(kv_tuple)158        back = to_tuple_kv(dynamic)159        # Shapes should be preserved160        assert back[0][0].shape == kv_tuple[0][0].shape161