|
3 | 3 |
|
4 | 4 | """Tests for the evaluation caching functionality.""" |
5 | 5 |
|
| 6 | +import tempfile |
| 7 | +import threading |
6 | 8 | from pathlib import Path |
7 | 9 |
|
8 | 10 | import pytest |
9 | 11 |
|
| 12 | +from gepa.core.adapter import EvaluationBatch |
10 | 13 | from gepa.core.state import EvaluationCache |
11 | 14 |
|
12 | 15 | # RECORDER_DIR paths for cached tests (imported lazily to avoid module conflicts) |
@@ -69,6 +72,214 @@ def test_put_batch(self): |
69 | 72 | assert cache.get(candidate, "ex2").objective_scores == {"acc": 0.8} |
70 | 73 |
|
71 | 74 |
|
| 75 | +class TestDiskCache: |
| 76 | + """Tests for disk write-through caching.""" |
| 77 | + |
| 78 | + def test_disk_cache_writes_and_loads(self): |
| 79 | + """Entries written with disk cache enabled should be loadable from a fresh cache.""" |
| 80 | + with tempfile.TemporaryDirectory() as tmp_dir: |
| 81 | + cache_dir = Path(tmp_dir) / "eval_cache" |
| 82 | + |
| 83 | + # Write entries |
| 84 | + cache1: EvaluationCache = EvaluationCache() |
| 85 | + cache1.enable_disk_cache(cache_dir) |
| 86 | + cache1.put({"prompt": "hello"}, "ex1", "out1", 0.9, {"acc": 0.95}) |
| 87 | + cache1.put({"prompt": "hello"}, "ex2", "out2", 0.8) |
| 88 | + |
| 89 | + pkl_files = list(cache_dir.glob("*.pkl")) |
| 90 | + assert len(pkl_files) == 2 |
| 91 | + |
| 92 | + # Load into a fresh cache |
| 93 | + cache2: EvaluationCache = EvaluationCache() |
| 94 | + cache2.enable_disk_cache(cache_dir) |
| 95 | + assert cache2.get({"prompt": "hello"}, "ex1") is not None |
| 96 | + assert cache2.get({"prompt": "hello"}, "ex1").score == 0.9 |
| 97 | + assert cache2.get({"prompt": "hello"}, "ex2").output == "out2" |
| 98 | + |
| 99 | + def test_disk_cache_put_batch(self): |
| 100 | + """put_batch should write individual .pkl files for each entry.""" |
| 101 | + with tempfile.TemporaryDirectory() as tmp_dir: |
| 102 | + cache_dir = Path(tmp_dir) / "eval_cache" |
| 103 | + cache: EvaluationCache = EvaluationCache() |
| 104 | + cache.enable_disk_cache(cache_dir) |
| 105 | + cache.put_batch( |
| 106 | + {"prompt": "test"}, ["ex1", "ex2", "ex3"], |
| 107 | + ["o1", "o2", "o3"], [0.1, 0.2, 0.3], |
| 108 | + ) |
| 109 | + assert len(list(cache_dir.glob("*.pkl"))) == 3 |
| 110 | + |
| 111 | + def test_disk_cache_atomic_write(self): |
| 112 | + """No .tmp files should be left after successful writes.""" |
| 113 | + with tempfile.TemporaryDirectory() as tmp_dir: |
| 114 | + cache_dir = Path(tmp_dir) / "eval_cache" |
| 115 | + cache: EvaluationCache = EvaluationCache() |
| 116 | + cache.enable_disk_cache(cache_dir) |
| 117 | + cache.put({"p": "v"}, "ex1", "out", 1.0) |
| 118 | + assert len(list(cache_dir.glob("*.tmp"))) == 0 |
| 119 | + |
| 120 | + |
| 121 | +class TestTrajectoryCache: |
| 122 | + """Tests for trajectory caching in EvaluationCache.""" |
| 123 | + |
| 124 | + def test_put_and_get_with_trajectory(self): |
| 125 | + """Trajectories should be stored and retrievable.""" |
| 126 | + cache: EvaluationCache = EvaluationCache() |
| 127 | + cache.put({"p": "v"}, "ex1", "out", 0.5, trajectory={"trace": [1, 2, 3]}) |
| 128 | + entry = cache.get({"p": "v"}, "ex1") |
| 129 | + assert entry is not None |
| 130 | + assert entry.trajectory == {"trace": [1, 2, 3]} |
| 131 | + |
| 132 | + def test_put_batch_with_trajectories(self): |
| 133 | + """put_batch should store trajectories when provided.""" |
| 134 | + cache: EvaluationCache = EvaluationCache() |
| 135 | + cache.put_batch( |
| 136 | + {"p": "v"}, ["ex1", "ex2"], |
| 137 | + ["o1", "o2"], [0.5, 0.6], |
| 138 | + trajectories=[{"t": 1}, {"t": 2}], |
| 139 | + ) |
| 140 | + assert cache.get({"p": "v"}, "ex1").trajectory == {"t": 1} |
| 141 | + assert cache.get({"p": "v"}, "ex2").trajectory == {"t": 2} |
| 142 | + |
| 143 | + def test_entry_without_trajectory_is_none(self): |
| 144 | + """Entries stored without trajectory should have trajectory=None.""" |
| 145 | + cache: EvaluationCache = EvaluationCache() |
| 146 | + cache.put({"p": "v"}, "ex1", "out", 0.5) |
| 147 | + assert cache.get({"p": "v"}, "ex1").trajectory is None |
| 148 | + |
| 149 | + |
| 150 | +class TestEvaluateBatchWithCache: |
| 151 | + """Tests for evaluate_batch_with_cache method.""" |
| 152 | + |
| 153 | + @staticmethod |
| 154 | + def _make_evaluator(call_log: list): |
| 155 | + """Create a mock evaluator that tracks calls and returns EvaluationBatch.""" |
| 156 | + def evaluator(batch, candidate, capture_traces=False): |
| 157 | + call_log.append({"batch_size": len(batch), "capture_traces": capture_traces}) |
| 158 | + outputs = [f"out_{i}" for i in range(len(batch))] |
| 159 | + scores = [0.5 + i * 0.1 for i in range(len(batch))] |
| 160 | + trajectories = [{"trace": i} for i in range(len(batch))] if capture_traces else None |
| 161 | + return EvaluationBatch(outputs=outputs, scores=scores, trajectories=trajectories) |
| 162 | + return evaluator |
| 163 | + |
| 164 | + @staticmethod |
| 165 | + def _make_fetcher(data: dict): |
| 166 | + """Create a fetcher that returns examples by ID.""" |
| 167 | + def fetcher(ids): |
| 168 | + return [data[eid] for eid in ids] |
| 169 | + return fetcher |
| 170 | + |
| 171 | + def test_all_misses(self): |
| 172 | + """When cache is empty, all examples should be evaluated.""" |
| 173 | + cache: EvaluationCache = EvaluationCache() |
| 174 | + call_log = [] |
| 175 | + data = {"ex1": {"d": 1}, "ex2": {"d": 2}} |
| 176 | + |
| 177 | + result, num_evals = cache.evaluate_batch_with_cache( |
| 178 | + {"p": "v"}, ["ex1", "ex2"], |
| 179 | + self._make_fetcher(data), self._make_evaluator(call_log), |
| 180 | + ) |
| 181 | + assert num_evals == 2 |
| 182 | + assert len(call_log) == 1 |
| 183 | + assert result.outputs == ["out_0", "out_1"] |
| 184 | + |
| 185 | + def test_all_hits(self): |
| 186 | + """When all entries are cached, no evaluation should happen.""" |
| 187 | + cache: EvaluationCache = EvaluationCache() |
| 188 | + cache.put({"p": "v"}, "ex1", "cached_out1", 0.9) |
| 189 | + cache.put({"p": "v"}, "ex2", "cached_out2", 0.8) |
| 190 | + call_log = [] |
| 191 | + |
| 192 | + result, num_evals = cache.evaluate_batch_with_cache( |
| 193 | + {"p": "v"}, ["ex1", "ex2"], |
| 194 | + self._make_fetcher({}), self._make_evaluator(call_log), |
| 195 | + ) |
| 196 | + assert num_evals == 0 |
| 197 | + assert len(call_log) == 0 |
| 198 | + assert result.outputs == ["cached_out1", "cached_out2"] |
| 199 | + assert result.scores == [0.9, 0.8] |
| 200 | + |
| 201 | + def test_partial_hits(self): |
| 202 | + """Mix of cached and uncached should only evaluate misses.""" |
| 203 | + cache: EvaluationCache = EvaluationCache() |
| 204 | + cache.put({"p": "v"}, "ex1", "cached_out", 0.9) |
| 205 | + call_log = [] |
| 206 | + data = {"ex2": {"d": 2}} |
| 207 | + |
| 208 | + result, num_evals = cache.evaluate_batch_with_cache( |
| 209 | + {"p": "v"}, ["ex1", "ex2"], |
| 210 | + self._make_fetcher(data), self._make_evaluator(call_log), |
| 211 | + ) |
| 212 | + assert num_evals == 1 |
| 213 | + assert len(call_log) == 1 |
| 214 | + assert call_log[0]["batch_size"] == 1 |
| 215 | + # Order preserved: ex1 from cache, ex2 from evaluator |
| 216 | + assert result.outputs[0] == "cached_out" |
| 217 | + |
| 218 | + def test_require_trajectories_re_evaluates_missing(self): |
| 219 | + """With require_trajectories=True, entries without trajectories are re-evaluated.""" |
| 220 | + cache: EvaluationCache = EvaluationCache() |
| 221 | + # Cache entry WITHOUT trajectory |
| 222 | + cache.put({"p": "v"}, "ex1", "old_out", 0.5) |
| 223 | + # Cache entry WITH trajectory |
| 224 | + cache.put({"p": "v"}, "ex2", "traj_out", 0.7, trajectory={"trace": "ok"}) |
| 225 | + call_log = [] |
| 226 | + data = {"ex1": {"d": 1}} |
| 227 | + |
| 228 | + result, num_evals = cache.evaluate_batch_with_cache( |
| 229 | + {"p": "v"}, ["ex1", "ex2"], |
| 230 | + self._make_fetcher(data), self._make_evaluator(call_log), |
| 231 | + require_trajectories=True, |
| 232 | + ) |
| 233 | + # ex1 should be re-evaluated (no trajectory), ex2 should be cached |
| 234 | + assert num_evals == 1 |
| 235 | + assert len(call_log) == 1 |
| 236 | + assert call_log[0]["capture_traces"] is True |
| 237 | + # ex2 should come from cache with its trajectory |
| 238 | + assert result.scores[1] == 0.7 |
| 239 | + assert result.trajectories[1] == {"trace": "ok"} |
| 240 | + |
| 241 | + def test_populates_cache_after_eval(self): |
| 242 | + """Evaluated entries should be stored in cache for future hits.""" |
| 243 | + cache: EvaluationCache = EvaluationCache() |
| 244 | + call_log = [] |
| 245 | + data = {"ex1": {"d": 1}} |
| 246 | + |
| 247 | + cache.evaluate_batch_with_cache( |
| 248 | + {"p": "v"}, ["ex1"], |
| 249 | + self._make_fetcher(data), self._make_evaluator(call_log), |
| 250 | + ) |
| 251 | + # Should now be cached |
| 252 | + assert cache.get({"p": "v"}, "ex1") is not None |
| 253 | + assert cache.get({"p": "v"}, "ex1").output == "out_0" |
| 254 | + |
| 255 | + |
| 256 | +class TestThreadSafety: |
| 257 | + """Tests for thread-safe cache operations.""" |
| 258 | + |
| 259 | + def test_concurrent_puts(self): |
| 260 | + """Concurrent puts from multiple threads should not lose entries.""" |
| 261 | + cache: EvaluationCache = EvaluationCache() |
| 262 | + errors = [] |
| 263 | + |
| 264 | + def put_range(start, count): |
| 265 | + try: |
| 266 | + for i in range(start, start + count): |
| 267 | + cache.put({"p": "v"}, f"ex_{i}", f"out_{i}", float(i)) |
| 268 | + except Exception as e: |
| 269 | + errors.append(e) |
| 270 | + |
| 271 | + threads = [threading.Thread(target=put_range, args=(i * 100, 100)) for i in range(4)] |
| 272 | + for t in threads: |
| 273 | + t.start() |
| 274 | + for t in threads: |
| 275 | + t.join() |
| 276 | + |
| 277 | + assert len(errors) == 0 |
| 278 | + # All 400 entries should be present |
| 279 | + for i in range(400): |
| 280 | + assert cache.get({"p": "v"}, f"ex_{i}") is not None |
| 281 | + |
| 282 | + |
72 | 283 | class TestEvaluationCacheIntegration: |
73 | 284 | """Integration tests for evaluation cache with optimize function.""" |
74 | 285 |
|
|
0 commit comments