remdms Claude Sonnet 4.6 commited on
Commit
f4eb869
·
1 Parent(s): 311089e

test: add unit tests for eval/runner.py

Browse files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files changed (1) hide show
  1. tests/test_eval_runner.py +252 -0
tests/test_eval_runner.py ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for eval/runner.py — pure/testable functions only (no ChromaDB)."""
2
+ import json
3
+ import pytest
4
+ from pathlib import Path
5
+
6
+ from mediastorm.eval.runner import _avg, _build_run_data, save_run, load_previous_run, load_all_runs
7
+
8
+
9
+ # ---------------------------------------------------------------------------
10
+ # Fixtures
11
+ # ---------------------------------------------------------------------------
12
+
13
+ def _make_row(category: str, **overrides) -> dict:
14
+ base = {
15
+ "query": "test query",
16
+ "category": category,
17
+ "precision_at_1": 1.0,
18
+ "recall_at_5": 0.8,
19
+ "mrr": 0.9,
20
+ "ndcg_at_5": 0.85,
21
+ "retrieved": ["uid_a", "uid_b"],
22
+ "expected": ["uid_a"],
23
+ "missed": [],
24
+ "duration": 0.1,
25
+ }
26
+ base.update(overrides)
27
+ return base
28
+
29
+
30
+ def _make_edge_row(**overrides) -> dict:
31
+ base = {
32
+ "query": "edge query",
33
+ "category": "edge_no_match",
34
+ "success": True,
35
+ "num_returned": 0,
36
+ "duration": 0.05,
37
+ }
38
+ base.update(overrides)
39
+ return base
40
+
41
+
42
+ def _make_eval_result(details: list[dict]) -> dict:
43
+ return {
44
+ "details": details,
45
+ "semantic_precision_at_1": 0.8,
46
+ "semantic_recall_at_5": 0.7,
47
+ "semantic_mrr": 0.75,
48
+ "semantic_ndcg_at_5": 0.72,
49
+ "filter_precision_at_1": 0.6,
50
+ "filter_recall_at_5": 0.65,
51
+ "edge_pass_rate": 1.0,
52
+ }
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # _avg
57
+ # ---------------------------------------------------------------------------
58
+
59
+ class TestAvg:
60
+ def test_averages_key_values(self):
61
+ rows = [{"score": 0.5}, {"score": 1.0}]
62
+ assert _avg(rows, "score") == pytest.approx(0.75)
63
+
64
+ def test_skips_rows_missing_key(self):
65
+ rows = [{"score": 1.0}, {"other": 0.0}]
66
+ assert _avg(rows, "score") == pytest.approx(1.0)
67
+
68
+ def test_empty_list_returns_zero(self):
69
+ assert _avg([], "score") == 0.0
70
+
71
+ def test_all_rows_missing_key_returns_zero(self):
72
+ rows = [{"other": 1.0}, {"other": 2.0}]
73
+ assert _avg(rows, "score") == 0.0
74
+
75
+ def test_single_row(self):
76
+ rows = [{"val": 0.42}]
77
+ assert _avg(rows, "val") == pytest.approx(0.42)
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # _build_run_data
82
+ # ---------------------------------------------------------------------------
83
+
84
+ class TestBuildRunData:
85
+ def test_timestamp_is_iso_string(self):
86
+ result = _build_run_data(_make_eval_result([_make_row("geographic")]))
87
+ ts = result["timestamp"]
88
+ assert isinstance(ts, str)
89
+ # Should parse back without error
90
+ from datetime import datetime
91
+ datetime.fromisoformat(ts)
92
+
93
+ def test_aggregates_keys(self):
94
+ result = _build_run_data(_make_eval_result([_make_row("thematic")]))
95
+ agg = result["aggregates"]
96
+ assert set(agg.keys()) == {
97
+ "semantic_p1", "semantic_r5", "semantic_mrr", "semantic_ndcg5",
98
+ "filter_p1", "filter_r5", "edge_pass_rate",
99
+ }
100
+
101
+ def test_aggregates_values_passthrough(self):
102
+ eval_result = _make_eval_result([_make_row("geographic")])
103
+ result = _build_run_data(eval_result)
104
+ agg = result["aggregates"]
105
+ assert agg["semantic_p1"] == pytest.approx(0.8)
106
+ assert agg["filter_r5"] == pytest.approx(0.65)
107
+ assert agg["edge_pass_rate"] == pytest.approx(1.0)
108
+
109
+ def test_category_summary_for_normal_category(self):
110
+ rows = [
111
+ _make_row("geographic", precision_at_1=1.0, recall_at_5=0.6, mrr=0.8, ndcg_at_5=0.7),
112
+ _make_row("geographic", precision_at_1=0.0, recall_at_5=0.4, mrr=0.5, ndcg_at_5=0.3),
113
+ ]
114
+ result = _build_run_data(_make_eval_result(rows))
115
+ cat = result["categories"]["geographic"]
116
+ assert cat["count"] == 2
117
+ assert cat["p1"] == pytest.approx(0.5)
118
+ assert cat["r5"] == pytest.approx(0.5)
119
+ assert cat["mrr"] == pytest.approx(0.65)
120
+ assert cat["ndcg5"] == pytest.approx(0.5)
121
+
122
+ def test_category_summary_for_edge_no_match(self):
123
+ rows = [
124
+ _make_edge_row(success=True),
125
+ _make_edge_row(success=False),
126
+ _make_edge_row(success=True),
127
+ ]
128
+ result = _build_run_data(_make_eval_result(rows))
129
+ edge = result["categories"]["edge_no_match"]
130
+ assert edge["passed"] == 2
131
+ assert edge["total"] == 3
132
+
133
+ def test_multiple_categories_separated(self):
134
+ rows = [
135
+ _make_row("geographic"),
136
+ _make_row("thematic"),
137
+ _make_row("geographic"),
138
+ ]
139
+ result = _build_run_data(_make_eval_result(rows))
140
+ assert result["categories"]["geographic"]["count"] == 2
141
+ assert result["categories"]["thematic"]["count"] == 1
142
+
143
+ def test_queries_list_length_matches_details(self):
144
+ rows = [_make_row("geographic"), _make_row("thematic"), _make_edge_row()]
145
+ result = _build_run_data(_make_eval_result(rows))
146
+ assert len(result["queries"]) == 3
147
+
148
+ def test_normal_query_entry_has_expected_keys(self):
149
+ result = _build_run_data(_make_eval_result([_make_row("geographic")]))
150
+ q = result["queries"][0]
151
+ assert "query" in q
152
+ assert "p1" in q
153
+ assert "r5" in q
154
+ assert "mrr" in q
155
+ assert "ndcg5" in q
156
+ assert "retrieved_ids" in q
157
+ assert "expected_ids" in q
158
+ assert "missed" in q
159
+ assert "duration" in q
160
+ # Edge-only fields should not be present
161
+ assert "success" not in q
162
+ assert "num_returned" not in q
163
+
164
+ def test_edge_query_entry_has_expected_keys(self):
165
+ result = _build_run_data(_make_eval_result([_make_edge_row()]))
166
+ q = result["queries"][0]
167
+ assert "success" in q
168
+ assert "num_returned" in q
169
+ # Metric fields should not be present
170
+ assert "p1" not in q
171
+ assert "retrieved_ids" not in q
172
+
173
+ def test_returns_dict_with_required_top_level_keys(self):
174
+ result = _build_run_data(_make_eval_result([_make_row("geographic")]))
175
+ assert set(result.keys()) >= {"timestamp", "aggregates", "categories", "queries"}
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # save_run / load_previous_run / load_all_runs
180
+ # ---------------------------------------------------------------------------
181
+
182
+ class TestRunPersistence:
183
+ def _sample_run(self, timestamp: str = "2026-01-15T10:30:00") -> dict:
184
+ return {
185
+ "timestamp": timestamp,
186
+ "aggregates": {"semantic_p1": 0.8},
187
+ "categories": {},
188
+ "queries": [],
189
+ }
190
+
191
+ def test_save_run_creates_json_file(self, tmp_path):
192
+ run = self._sample_run()
193
+ path = save_run(run, runs_dir=tmp_path)
194
+ assert path.exists()
195
+ assert path.suffix == ".json"
196
+
197
+ def test_save_run_filename_matches_timestamp(self, tmp_path):
198
+ run = self._sample_run("2026-01-15T10:30:00")
199
+ path = save_run(run, runs_dir=tmp_path)
200
+ assert path.name == "2026-01-15_10-30-00.json"
201
+
202
+ def test_save_run_content_is_valid_json(self, tmp_path):
203
+ run = self._sample_run()
204
+ path = save_run(run, runs_dir=tmp_path)
205
+ loaded = json.loads(path.read_text())
206
+ assert loaded["aggregates"]["semantic_p1"] == pytest.approx(0.8)
207
+
208
+ def test_save_run_creates_parent_dirs(self, tmp_path):
209
+ nested = tmp_path / "deep" / "nested"
210
+ run = self._sample_run()
211
+ save_run(run, runs_dir=nested)
212
+ assert nested.exists()
213
+
214
+ def test_load_previous_run_returns_none_when_no_dir(self, tmp_path):
215
+ missing = tmp_path / "nonexistent"
216
+ assert load_previous_run(runs_dir=missing) is None
217
+
218
+ def test_load_previous_run_returns_none_when_empty_dir(self, tmp_path):
219
+ assert load_previous_run(runs_dir=tmp_path) is None
220
+
221
+ def test_load_previous_run_returns_most_recent(self, tmp_path):
222
+ run_a = self._sample_run("2026-01-15T10:00:00")
223
+ run_b = self._sample_run("2026-01-15T11:00:00")
224
+ save_run(run_a, runs_dir=tmp_path)
225
+ save_run(run_b, runs_dir=tmp_path)
226
+ loaded = load_previous_run(runs_dir=tmp_path)
227
+ assert loaded["timestamp"] == "2026-01-15T11:00:00"
228
+
229
+ def test_load_all_runs_returns_empty_when_no_dir(self, tmp_path):
230
+ missing = tmp_path / "nonexistent"
231
+ assert load_all_runs(runs_dir=missing) == []
232
+
233
+ def test_load_all_runs_returns_empty_when_empty_dir(self, tmp_path):
234
+ assert load_all_runs(runs_dir=tmp_path) == []
235
+
236
+ def test_load_all_runs_returns_all_in_order(self, tmp_path):
237
+ timestamps = [
238
+ "2026-01-10T09:00:00",
239
+ "2026-01-15T11:00:00",
240
+ "2026-01-12T14:00:00",
241
+ ]
242
+ for ts in timestamps:
243
+ save_run(self._sample_run(ts), runs_dir=tmp_path)
244
+ runs = load_all_runs(runs_dir=tmp_path)
245
+ assert len(runs) == 3
246
+ result_ts = [r["timestamp"] for r in runs]
247
+ assert result_ts == sorted(result_ts)
248
+
249
+ def test_save_run_returns_path_object(self, tmp_path):
250
+ run = self._sample_run()
251
+ path = save_run(run, runs_dir=tmp_path)
252
+ assert isinstance(path, Path)