Spaces:
Runtime error
Runtime error
| """ | |
| Tests for Step 5 β Recipe Memoization Cache. | |
| Covers: | |
| β’ make_cache_key(): same bucket β same key; different bucket β different key | |
| β’ RecipeCache.get(): L1 miss returns None; L1 hit returns entry; TTL expiry evicts | |
| β’ RecipeCache.set(): grade A/B cached; grade C/D/F not cached | |
| β’ L1 LRU eviction at capacity | |
| β’ Supabase L2 failures are silent (L1-only fallback) | |
| β’ ModelAgent returns cache_hit=True in metadata on cache hit | |
| β’ Cache invalidation | |
| """ | |
| from __future__ import annotations | |
| import time | |
| from unittest.mock import MagicMock, patch | |
| import pytest | |
| from agents.cache import ( | |
| RecipeCache, | |
| CacheEntry, | |
| make_cache_key, | |
| _rows_bucket, | |
| _word_count_bucket, | |
| _CACHE_TTL_SECS, | |
| _L1_MAX_ENTRIES, | |
| _CACHEABLE_GRADES, | |
| ) | |
| # ββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _profile(num_rows: int = 500, num_classes: int = 3, avg_word_count: float = 50.0, | |
| label_noise: float = 0.0) -> dict: | |
| return { | |
| "num_rows": num_rows, | |
| "num_classes": num_classes, | |
| "avg_word_count": avg_word_count, | |
| "label_noise_estimate": label_noise, | |
| "label_distribution": {f"cls_{i}": 100 for i in range(num_classes)}, | |
| } | |
| def _entry(grade: str = "B", recipe: dict | None = None) -> CacheEntry: | |
| return CacheEntry( | |
| cache_key="dummy_key", | |
| model_recipe=recipe or {"base_model": "bert-base-uncased", "learning_rate": 2e-4}, | |
| eval_grade=grade, | |
| stored_at=time.time(), | |
| ) | |
| # ββ Bucket helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class TestBuckets: | |
| def test_rows_bucket_xs(self): | |
| assert _rows_bucket(0) == "xs" | |
| assert _rows_bucket(199) == "xs" | |
| def test_rows_bucket_sm(self): | |
| assert _rows_bucket(200) == "sm" | |
| assert _rows_bucket(999) == "sm" | |
| def test_rows_bucket_md(self): | |
| assert _rows_bucket(1_000) == "md" | |
| assert _rows_bucket(9_999) == "md" | |
| def test_rows_bucket_lg(self): | |
| assert _rows_bucket(10_000) == "lg" | |
| assert _rows_bucket(1_000_000) == "lg" | |
| def test_word_count_short(self): | |
| assert _word_count_bucket(0) == "short" | |
| assert _word_count_bucket(19.9) == "short" | |
| def test_word_count_medium(self): | |
| assert _word_count_bucket(20) == "medium" | |
| assert _word_count_bucket(99.9) == "medium" | |
| def test_word_count_long(self): | |
| assert _word_count_bucket(100) == "long" | |
| # ββ make_cache_key ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class TestMakeCacheKey: | |
| def test_same_bucket_same_key(self): | |
| p1 = _profile(num_rows=500, num_classes=3, avg_word_count=50) # sm, medium | |
| p2 = _profile(num_rows=700, num_classes=3, avg_word_count=60) # sm, medium β same bucket | |
| assert make_cache_key(p1, "text_classification") == make_cache_key(p2, "text_classification") | |
| def test_different_rows_bucket_different_key(self): | |
| p_sm = _profile(num_rows=500) # sm | |
| p_md = _profile(num_rows=2000) # md | |
| assert make_cache_key(p_sm, "text_classification") != make_cache_key(p_md, "text_classification") | |
| def test_different_class_count_different_key(self): | |
| p3 = _profile(num_classes=3) | |
| p10 = _profile(num_classes=10) | |
| assert make_cache_key(p3, "text_classification") != make_cache_key(p10, "text_classification") | |
| def test_different_task_type_different_key(self): | |
| p = _profile() | |
| assert make_cache_key(p, "text_classification") != make_cache_key(p, "token_classification") | |
| def test_key_is_deterministic(self): | |
| p = _profile() | |
| k1 = make_cache_key(p, "text_classification") | |
| k2 = make_cache_key(p, "text_classification") | |
| assert k1 == k2 | |
| def test_key_length(self): | |
| # Keys are 20-char hex strings (truncated SHA-256) | |
| key = make_cache_key(_profile(), "text_classification") | |
| assert len(key) == 20 | |
| def test_noisy_dataset_different_from_clean(self): | |
| clean = _profile(label_noise=0.05) # 5% β below 10% β clean tier | |
| noisy = _profile(label_noise=0.15) # 15% β above 10% β noisy tier | |
| assert make_cache_key(clean, "text_classification") != make_cache_key(noisy, "text_classification") | |
| # ββ RecipeCache L1 ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class TestRecipeCacheL1: | |
| def setup_method(self): | |
| self.cache = RecipeCache() | |
| def test_miss_returns_none(self): | |
| assert self.cache.get(_profile(), "text_classification") is None | |
| def test_grade_a_is_cached(self): | |
| p = _profile() | |
| recipe = {"base_model": "bert-base-uncased", "learning_rate": 2e-4} | |
| self.cache.set(p, "text_classification", recipe, "A") | |
| entry = self.cache.get(p, "text_classification") | |
| assert entry is not None | |
| assert entry.eval_grade == "A" | |
| assert entry.model_recipe == recipe | |
| def test_grade_b_is_cached(self): | |
| p = _profile() | |
| self.cache.set(p, "text_classification", {}, "B") | |
| assert self.cache.get(p, "text_classification") is not None | |
| def test_grade_c_not_cached(self): | |
| p = _profile() | |
| self.cache.set(p, "text_classification", {}, "C") | |
| assert self.cache.get(p, "text_classification") is None | |
| def test_grade_d_not_cached(self): | |
| self.cache.set(_profile(), "text_classification", {}, "D") | |
| assert self.cache.get(_profile(), "text_classification") is None | |
| def test_grade_f_not_cached(self): | |
| self.cache.set(_profile(), "text_classification", {}, "F") | |
| assert self.cache.get(_profile(), "text_classification") is None | |
| def test_all_cacheable_grades(self): | |
| for grade in _CACHEABLE_GRADES: | |
| c = RecipeCache() | |
| c.set(_profile(), "text_classification", {}, grade) | |
| assert c.get(_profile(), "text_classification") is not None | |
| def test_size_increases_on_set(self): | |
| assert self.cache.size() == 0 | |
| self.cache.set(_profile(num_classes=2), "text_classification", {}, "A") | |
| assert self.cache.size() == 1 | |
| def test_same_key_overwritten(self): | |
| p = _profile() | |
| self.cache.set(p, "text_classification", {"base_model": "old"}, "A") | |
| self.cache.set(p, "text_classification", {"base_model": "new"}, "B") | |
| entry = self.cache.get(p, "text_classification") | |
| assert entry is not None | |
| assert entry.model_recipe["base_model"] == "new" | |
| # ββ TTL expiry ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class TestTTLExpiry: | |
| def test_expired_entry_returns_none(self): | |
| cache = RecipeCache() | |
| p = _profile() | |
| cache.set(p, "text_classification", {}, "A") | |
| # Manually age the entry past TTL | |
| key = make_cache_key(p, "text_classification") | |
| cache._l1[key].stored_at = time.time() - _CACHE_TTL_SECS - 1 | |
| result = cache.get(p, "text_classification") | |
| assert result is None | |
| def test_expired_entry_removed_from_l1(self): | |
| cache = RecipeCache() | |
| p = _profile() | |
| cache.set(p, "text_classification", {}, "A") | |
| key = make_cache_key(p, "text_classification") | |
| cache._l1[key].stored_at = time.time() - _CACHE_TTL_SECS - 1 | |
| cache.get(p, "text_classification") # triggers eviction | |
| assert cache.size() == 0 | |
| # ββ LRU eviction at capacity ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class TestLRUEviction: | |
| def test_evicts_oldest_when_at_capacity(self): | |
| cache = RecipeCache() | |
| # Fill to capacity with distinct keys (vary num_classes to get distinct keys) | |
| for i in range(_L1_MAX_ENTRIES): | |
| cache.set(_profile(num_classes=i + 1), "text_classification", {"i": i}, "A") | |
| assert cache.size() == _L1_MAX_ENTRIES | |
| # Adding one more should evict the oldest (num_classes=1, first inserted) | |
| cache.set(_profile(num_classes=_L1_MAX_ENTRIES + 1), "text_classification", {}, "B") | |
| assert cache.size() == _L1_MAX_ENTRIES # still at cap | |
| # The first-inserted key (num_classes=1) should have been evicted | |
| first = cache.get(_profile(num_classes=1), "text_classification") | |
| assert first is None # LRU-evicted | |
| # ββ Invalidation and clear ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class TestInvalidation: | |
| def test_invalidate_removes_entry(self): | |
| cache = RecipeCache() | |
| p = _profile() | |
| cache.set(p, "text_classification", {}, "A") | |
| cache.invalidate(p, "text_classification") | |
| assert cache.get(p, "text_classification") is None | |
| def test_clear_empties_l1(self): | |
| cache = RecipeCache() | |
| for i in range(5): | |
| cache.set(_profile(num_classes=i + 1), "text_classification", {}, "A") | |
| cache.clear() | |
| assert cache.size() == 0 | |
| # ββ Supabase L2 failure is silent βββββββββββββββββββββββββββββββββββββββββββββ | |
| class TestSupabaseFallback: | |
| def test_supabase_get_failure_falls_back_silently(self): | |
| """If Supabase raises, get() returns None without crashing.""" | |
| cache = RecipeCache() | |
| with patch("agents.cache._l2_get", side_effect=Exception("DB down")): | |
| # Should not raise, just return None (L1 miss + L2 error = miss) | |
| result = cache.get(_profile(), "text_classification") | |
| assert result is None | |
| def test_supabase_set_failure_is_silent(self): | |
| """If Supabase L2 write fails, set() still populates L1.""" | |
| cache = RecipeCache() | |
| with patch("agents.cache._l2_set", side_effect=Exception("DB down")): | |
| cache.set(_profile(), "text_classification", {"base_model": "bert"}, "A") | |
| # L1 should still have the entry | |
| assert cache.size() == 1 | |
| def test_no_supabase_env_vars_means_l1_only(self): | |
| """Without SUPABASE_URL/KEY, L2 operations are no-ops.""" | |
| with patch.dict("os.environ", {}, clear=True): | |
| # _supabase_client() returns None β L2 skipped | |
| from agents.cache import _supabase_client | |
| assert _supabase_client() is None | |