Skip to content

Commit abcaa82

Browse files
committed
Add end-to-end tests for disk cache, trajectory caching, evaluate_batch_with_cache, and thread safety
1 parent 36397ca commit abcaa82

1 file changed

Lines changed: 211 additions & 0 deletions

File tree

tests/test_evaluation_cache.py

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@
33

44
"""Tests for the evaluation caching functionality."""
55

6+
import tempfile
7+
import threading
68
from pathlib import Path
79

810
import pytest
911

12+
from gepa.core.adapter import EvaluationBatch
1013
from gepa.core.state import EvaluationCache
1114

1215
# RECORDER_DIR paths for cached tests (imported lazily to avoid module conflicts)
@@ -69,6 +72,214 @@ def test_put_batch(self):
6972
assert cache.get(candidate, "ex2").objective_scores == {"acc": 0.8}
7073

7174

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+
72283
class TestEvaluationCacheIntegration:
73284
"""Integration tests for evaluation cache with optimize function."""
74285

0 commit comments

Comments
 (0)