Skip to content

Commit f2ed694

Browse files
committed
feat: add thematic coverage % to deck descriptions
Coverage = unique themes in sampled deck / unique themes in full ELO tranche before filtering, computed at CSV generation time. lichess_optimized_puzzles_datasets.py: - report_theme_coverage() now returns a stats dict {selected, unique_themes_sample, unique_themes_tranche, coverage_pct} in addition to printing the existing report. - extract_tranches() collects those dicts and writes puzzles_stats.json alongside the puzzle CSVs so build_apkg.py can read them. build_apkg.py: - Add _load_deck_stats(csv_dir) helper to read puzzles_stats.json. - _build_description() accepts an optional coverage float and appends "(74.3% of tranche themes)" inline with the theme list when present. - build_from_csvs() loads stats once and passes coverage_pct per deck. - Move media path to module constant MEDIA_PATH to stay within pylint's 20-local-variable limit (was 22 after new locals were added). 5 new tests cover coverage display, absence when None, and JSON loading. https://claude.ai/code/session_01VAUnQCt5CM2TVpRbsQbSBL
1 parent cf4da24 commit f2ed694

3 files changed

Lines changed: 90 additions & 16 deletions

File tree

build_apkg.py

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,18 @@
1313
import argparse
1414
import csv
1515
import hashlib
16+
import json
1617
import os
1718
import shutil
1819
import zipfile
1920
from pathlib import Path
20-
from typing import List, Dict
21+
from typing import Dict, List, Optional
2122

2223
import genanki
2324

2425
DECK_PARENT = "♟️ Optimized Chess Puzzles"
2526
MODEL_NAME = "Chess Optimized Tactics"
27+
MEDIA_PATH = os.path.join("♟️_Optimized_Chess_Puzzles", "media", "_chess_merida_unicode.ttf")
2628

2729
# Stable ID matching the reference deck (do not change — Anki uses it to
2830
# identify the note type when merging with existing collections).
@@ -174,12 +176,22 @@ def _row_to_note(row: Dict[str, str], model: genanki.Model) -> PuzzleNote:
174176
)
175177

176178

177-
def _build_description(rows: List[Dict[str, str]]) -> str:
179+
def _load_deck_stats(csv_dir: str) -> Dict[str, Dict]:
180+
"""Load per-tranche coverage stats written by lichess_optimized_puzzles_datasets."""
181+
path = os.path.join(csv_dir, "puzzles_stats.json")
182+
if not os.path.exists(path):
183+
return {}
184+
with open(path, encoding="utf-8") as f:
185+
return json.load(f)
186+
187+
188+
def _build_description(rows: List[Dict[str, str]], coverage: Optional[float] = None) -> str:
178189
"""Build a plain-text deck description from a list of puzzle rows.
179190
180191
Includes puzzle count, ELO range/average, popularity average, and the top
181192
themes sorted by frequency — mirroring what lichess_optimized_puzzles_datasets
182-
reports at generation time.
193+
reports at generation time. When coverage is provided (ratio of unique themes
194+
in the sample vs the full ELO tranche), it is shown inline with the theme list.
183195
"""
184196
count = len(rows)
185197
if count == 0:
@@ -205,9 +217,10 @@ def _build_description(rows: List[Dict[str, str]]) -> str:
205217

206218
if theme_counts:
207219
n_themes = len(theme_counts)
220+
cov_label = f" ({coverage:.1f}% of tranche themes)" if coverage is not None else ""
208221
top = sorted(theme_counts.items(), key=lambda x: -x[1])[:15]
209222
lines.append(
210-
f"\n{n_themes} theme{'s' if n_themes != 1 else ''}: "
223+
f"\n{n_themes} theme{'s' if n_themes != 1 else ''}{cov_label}: "
211224
+ " · ".join(f"{t} ({n})" for t, n in top)
212225
)
213226

@@ -219,10 +232,7 @@ def build_from_csvs(csv_dir: str, output: str) -> None:
219232
front, back, css = _load_templates()
220233
model = _build_model(front, back, css)
221234

222-
media_path = os.path.join(
223-
"♟️_Optimized_Chess_Puzzles", "media", "_chess_merida_unicode.ttf"
224-
)
225-
235+
deck_stats = _load_deck_stats(csv_dir)
226236
decks: List[genanki.Deck] = []
227237
all_rows: List[Dict[str, str]] = []
228238
total_notes = 0
@@ -240,7 +250,10 @@ def build_from_csvs(csv_dir: str, output: str) -> None:
240250

241251
all_rows.extend(rows)
242252
deck = genanki.Deck(
243-
_deck_id(deck_name), deck_name, description=_build_description(rows)
253+
_deck_id(deck_name), deck_name,
254+
description=_build_description(
255+
rows, (deck_stats.get(csv_filename) or {}).get("coverage_pct")
256+
),
244257
)
245258
for row in rows:
246259
deck.add_note(_row_to_note(row, model))
@@ -259,8 +272,8 @@ def build_from_csvs(csv_dir: str, output: str) -> None:
259272
decks.insert(0, parent_deck)
260273

261274
package = genanki.Package(decks)
262-
if os.path.exists(media_path):
263-
package.media_files = [media_path]
275+
if os.path.exists(MEDIA_PATH):
276+
package.media_files = [MEDIA_PATH]
264277

265278
package.write_to_file(output)
266279
_upgrade_to_anki21(output)

lichess_optimized_puzzles_datasets.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,12 @@
3232
provide comprehensive coverage of tactical themes and opening patterns.
3333
"""
3434

35+
import json
3536
import os
3637
import re
3738
import subprocess
3839
from collections import defaultdict
39-
from typing import List, Tuple
40+
from typing import Dict, List, Tuple
4041

4142
import chess
4243
import pandas
@@ -232,6 +233,8 @@ def extract_tranches(
232233
233234
Creates separate CSV files for each ELO range with optimally selected puzzles.
234235
Ranges include: <1000, 1000-1100, 1100-1200, ..., 1700-1800, 1800+
236+
Also writes puzzles_stats.json with per-tranche coverage stats consumed by
237+
build_apkg.py when generating deck descriptions.
235238
236239
Parameters
237240
----------
@@ -245,6 +248,7 @@ def extract_tranches(
245248
dataframe = pandas.read_csv(csv_file)
246249
cols = ['PuzzleId', 'FEN', 'Moves', 'Rating', 'Popularity', 'Themes', 'OpeningTags']
247250
dataframe = dataframe[cols]
251+
all_stats: Dict[str, Dict] = {}
248252

249253
first_tranche = dataframe[dataframe['Rating'] < 1000]
250254
sampled_rows = sample_by_themes(
@@ -253,7 +257,9 @@ def extract_tranches(
253257
popularity_threshold=popularity_threshold
254258
)
255259
_write_csv_file(sampled_rows, "puzzles_1000minus.csv")
256-
report_theme_coverage(sampled_rows, "puzzles_1000minus.csv", first_tranche)
260+
all_stats["puzzles_1000minus.csv"] = report_theme_coverage(
261+
sampled_rows, "puzzles_1000minus.csv", first_tranche
262+
)
257263

258264
for elo_start in range(1000, 1800, 100):
259265
elo_end = elo_start + 100
@@ -265,7 +271,7 @@ def extract_tranches(
265271
)
266272
out_file = f"puzzles_{elo_start}_{elo_end}.csv"
267273
_write_csv_file(sampled_rows, out_file)
268-
report_theme_coverage(sampled_rows, out_file, tranche)
274+
all_stats[out_file] = report_theme_coverage(sampled_rows, out_file, tranche)
269275

270276
last_tranche = dataframe[dataframe['Rating'] >= 1800]
271277
sampled_rows = sample_by_themes(
@@ -274,7 +280,12 @@ def extract_tranches(
274280
popularity_threshold=popularity_threshold
275281
)
276282
_write_csv_file(sampled_rows, "puzzles_1800plus.csv")
277-
report_theme_coverage(sampled_rows, "puzzles_1800plus.csv", last_tranche)
283+
all_stats["puzzles_1800plus.csv"] = report_theme_coverage(
284+
sampled_rows, "puzzles_1800plus.csv", last_tranche
285+
)
286+
287+
with open("puzzles_stats.json", "w", encoding="utf-8") as stats_file:
288+
json.dump(all_stats, stats_file, indent=2)
278289

279290

280291
def _write_csv_file(sampled_rows: List, filename) -> None:
@@ -325,7 +336,7 @@ def report_theme_coverage(
325336
sampled_rows: List,
326337
out_file: str,
327338
tranche: pandas.DataFrame,
328-
) -> None:
339+
) -> Dict:
329340
"""
330341
Generate and display theme coverage statistics for the puzzle selection.
331342
@@ -340,6 +351,13 @@ def report_theme_coverage(
340351
Output filename for context
341352
tranche : pandas.DataFrame
342353
Original tranche data for comparison
354+
355+
Returns
356+
-------
357+
dict
358+
Stats dict with keys: selected, unique_themes_sample,
359+
unique_themes_tranche, coverage_pct. Consumed by build_apkg.py
360+
to populate deck descriptions.
343361
"""
344362
selected_themes: set = set()
345363
theme_freq: dict[str, int] = {}
@@ -376,6 +394,13 @@ def report_theme_coverage(
376394
print(f" • {theme}: {freq} puzzles")
377395
print("—" * 35)
378396

397+
return {
398+
"selected": len(sampled_rows),
399+
"unique_themes_sample": len(selected_themes),
400+
"unique_themes_tranche": len(tranche_themes),
401+
"coverage_pct": round(percentage_coverage, 1),
402+
}
403+
379404

380405
def main() -> None:
381406
"""

tests/build_apkg_test.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
_deck_id,
2121
_build_model,
2222
_build_description,
23+
_load_deck_stats,
2324
_row_to_note,
2425
build_sample,
2526
ALL_DECKS,
@@ -242,3 +243,38 @@ def test_sample_cards_have_description(self):
242243
desc = _build_description(list(build_apkg.SAMPLE_CARDS))
243244
assert "3 puzzles" in desc
244245
assert "fork" in desc
246+
247+
def test_coverage_shown_when_provided(self):
248+
rows = [_make_themed_row("p1", "fork pin")]
249+
desc = _build_description(rows, coverage=74.3)
250+
assert "74.3%" in desc
251+
assert "of tranche themes" in desc
252+
253+
def test_coverage_absent_when_none(self):
254+
rows = [_make_themed_row("p1", "fork pin")]
255+
desc = _build_description(rows, coverage=None)
256+
assert "tranche" not in desc
257+
258+
def test_coverage_100_percent(self):
259+
rows = [_make_themed_row("p1", "fork")]
260+
desc = _build_description(rows, coverage=100.0)
261+
assert "100.0%" in desc
262+
263+
264+
class TestLoadDeckStats:
265+
def test_returns_empty_dict_when_file_absent(self, tmp_path):
266+
assert _load_deck_stats(str(tmp_path)) == {}
267+
268+
def test_loads_coverage_pct(self, tmp_path):
269+
import json
270+
stats = {
271+
"puzzles_1000minus.csv": {
272+
"selected": 847,
273+
"unique_themes_sample": 23,
274+
"unique_themes_tranche": 31,
275+
"coverage_pct": 74.2,
276+
}
277+
}
278+
(tmp_path / "puzzles_stats.json").write_text(json.dumps(stats))
279+
loaded = _load_deck_stats(str(tmp_path))
280+
assert loaded["puzzles_1000minus.csv"]["coverage_pct"] == 74.2

0 commit comments

Comments
 (0)