Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,8 +302,11 @@ The deck is now available in your Anki collection, organized by ELO ranges with
- **Unified Tags**: Merged themes + openings for easy filtering
- **Direct links** to Lichess and Chess.com for deeper analysis
- **Metadata**: Rating, popularity for progress tracking
- **Confidence**: The Bayesian quality score computed at selection time (Popularity + NbPlays, confidence-weighted), shown on the back as a ★ pill so you can gauge how community-validated each puzzle is
- **Diplay theme** used for the card (available themes are *theme-solarized*, *theme-paper-sand* and nothing for default theme)

Every sub-deck also carries a **rich description** generated from the selection statistics: puzzle count, ELO range/average, average popularity, motif/theme coverage, a full theme-frequency breakdown, and a Woodpecker-based estimate of how long it takes to master the band (at ~20 new puzzles/day).

This project thus transforms a raw database of millions of puzzles into **custom training sets**, optimized for systematic progression and lasting memorization of tactical patterns essential at each level! 🚀♟️

***
Expand Down
92 changes: 72 additions & 20 deletions build_apkg.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import csv
import hashlib
import json
import math
import os
import shutil
import zipfile
Expand All @@ -39,8 +40,14 @@
{"name": "Themes"},
{"name": "Opening"},
{"name": "Display Theme"},
{"name": "Confidence"}, # Bayesian quality score in [0, 1] (appended last to
# keep field order stable for existing collections)
]

# Average daily intake of new puzzles recommended in the README ("~20 new
# puzzles per day"). Used to estimate the time to master a sub-deck.
NEW_PUZZLES_PER_DAY = 20


class PuzzleNote(genanki.Note):
"""genanki.Note whose GUID depends only on the Puzzle ID (fields[0]).
Expand Down Expand Up @@ -86,6 +93,7 @@ def guid(self):
"Themes": "fork sacrifice",
"Opening": "Italian",
"Display Theme": "theme-solarized",
"Confidence": "0.912",
"Tags": "OCP::fork OCP::sacrifice OCP::Italian",
},
{
Expand All @@ -97,6 +105,7 @@ def guid(self):
"Themes": "pin",
"Opening": "Italian",
"Display Theme": "theme-solarized",
"Confidence": "0.874",
"Tags": "OCP::pin OCP::Italian",
},
{
Expand All @@ -108,6 +117,7 @@ def guid(self):
"Themes": "endgame pawnEndgame",
"Opening": "",
"Display Theme": "theme-solarized",
"Confidence": "0.953",
"Tags": "OCP::endgame OCP::pawnEndgame",
},
]
Expand Down Expand Up @@ -174,6 +184,7 @@ def _row_to_note(row: Dict[str, str], model: genanki.Model) -> PuzzleNote:
row.get("Themes", ""),
row.get("Opening", ""),
row.get("Display Theme", "theme-solarized"),
row.get("Confidence", ""),
],
tags=tags,
)
Expand All @@ -188,13 +199,56 @@ def _load_deck_stats(csv_dir: str) -> Dict[str, Dict]:
return json.load(f)


def _build_description(rows: List[Dict[str, str]], coverage: Optional[float] = None) -> str:
def _mastery_sentence(count: int) -> str:
"""Motivational one-liner estimating how long to master a deck.

Uses the README's recommended pace of ~20 new puzzles/day for a first full
pass, then frames the Woodpecker method (repeated accelerated cycles) as the
path to turning calculation into reflex.
"""
days = math.ceil(count / NEW_PUZZLES_PER_DAY)
weeks = max(1, round(days / 7))
return (
f"🔨 Woodpecker plan: at ~{NEW_PUZZLES_PER_DAY} new puzzles/day, a first "
f"full pass takes about {days} days (~{weeks} week{'s' if weeks != 1 else ''}). "
"Then repeat the set in faster and faster cycles until the patterns become "
"reflexes — that's when you've mastered this deck. 🚀"
)


def _coverage_lines(stats: Dict) -> List[str]:
"""Render the coverage block from a per-tranche stats dict (missing keys skipped)."""
lines: List[str] = []
uts = stats.get("unique_themes_sample")
utt = stats.get("unique_themes_tranche")
cov = stats.get("coverage_pct")
if uts is None and utt is None and cov is None:
return lines

lines.append("")
lines.append("📈 Coverage:")
if uts is not None:
ums = stats.get("unique_motifs_sample")
suffix = f" (motifs: {ums})" if ums is not None else ""
lines.append(f"- Unique themes covered: {uts}{suffix}")
if utt is not None:
umt = stats.get("unique_motifs_tranche")
suffix = f" (motifs: {umt})" if umt is not None else ""
lines.append(f"- Distinct themes in tranche: {utt}{suffix}")
if cov is not None:
cov_all = stats.get("coverage_pct_all")
suffix = f" (all-theme coverage: {cov_all}%)" if cov_all is not None else ""
lines.append(f"- Motif coverage: {cov}%{suffix}")
return lines


def _build_description(rows: List[Dict[str, str]], stats: Optional[Dict] = None) -> str:
"""Build a plain-text deck description from a list of puzzle rows.

Includes puzzle count, ELO range/average, popularity average, and the top
themes sorted by frequency — mirroring what lichess_optimized_puzzles_datasets
reports at generation time. When coverage is provided (ratio of unique themes
in the sample vs the full ELO tranche), it is shown inline with the theme list.
Mirrors the report printed by lichess_optimized_puzzles_datasets at generation
time: a one-line summary (count, ELO range/average, popularity average), a
motivational mastery estimate, the per-tranche coverage stats (when *stats* is
provided), and the full theme-frequency breakdown sorted by frequency.
"""
count = len(rows)
if count == 0:
Expand All @@ -209,23 +263,23 @@ def _build_description(rows: List[Dict[str, str]], coverage: Optional[float] = N
ratings = [int(row["Rating"]) for row in rows if row.get("Rating", "").isdigit()]
pops = [int(row["Popularity"]) for row in rows if row.get("Popularity", "").isdigit()]

lines: List[str] = [f"{count} puzzle{'s' if count != 1 else ''}"]

summary = f"📊 {count} puzzle{'s' if count != 1 else ''}"
if ratings:
lines.append(
f"Rating: {min(ratings)}–{max(ratings)}, average {sum(ratings) // len(ratings)}"
)
summary += f" · Rating {min(ratings)}–{max(ratings)} (avg {sum(ratings) // len(ratings)})"
if pops:
lines.append(f"Popularity: average {sum(pops) // len(pops)}%")
summary += f" · Popularity avg {sum(pops) // len(pops)}%"

lines: List[str] = [summary, "", _mastery_sentence(count)]

if stats:
lines.extend(_coverage_lines(stats))

if theme_counts:
n_themes = len(theme_counts)
cov_label = f" ({coverage:.1f}% of tranche themes)" if coverage is not None else ""
top = sorted(theme_counts.items(), key=lambda x: -x[1])[:15]
lines.append(
f"\n{n_themes} theme{'s' if n_themes != 1 else ''}{cov_label}: "
+ " · ".join(f"{t} ({n})" for t, n in top)
)
lines.append("")
lines.append(f"🎯 {n_themes} theme{'s' if n_themes != 1 else ''} by frequency:")
for theme, freq in sorted(theme_counts.items(), key=lambda x: (-x[1], x[0])):
lines.append(f" • {theme}: {freq} puzzle{'s' if freq != 1 else ''}")

return "\n".join(lines)

Expand Down Expand Up @@ -264,9 +318,7 @@ def build_from_csvs(
all_rows.extend(rows)
deck = genanki.Deck(
_deck_id(deck_name), deck_name,
description=_build_description(
rows, (deck_stats.get(csv_filename) or {}).get("coverage_pct")
),
description=_build_description(rows, deck_stats.get(csv_filename)),
)
for row in rows:
deck.add_note(_row_to_note(row, model))
Expand Down
12 changes: 11 additions & 1 deletion lichess_optimized_puzzles_datasets.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,13 @@ def _meaningful_motifs(themes_str) -> List[str]:
return [t for t in str(themes_str).split() if t and t not in THEME_DENYLIST]


def _format_confidence(value) -> str:
"""Format the Bayesian quality score for CSV output (3 decimals, blank if absent)."""
if value is None or pandas.isna(value):
return ""
return f"{float(value):.3f}"


def _augment_tranche(
tranche: pandas.DataFrame,
popularity_threshold: int,
Expand Down Expand Up @@ -536,7 +543,7 @@ def _ocp_prefixed_tokens(text: str) -> str:

with open(filename, "w", encoding="utf-8") as puzzle_file:
puzzle_file.write(
"PuzzleID,FEN,Moves,Rating,Popularity,Themes,Opening,Display Theme,Tags\n"
"PuzzleID,FEN,Moves,Rating,Popularity,Themes,Opening,Display Theme,Confidence,Tags\n"
)

for row in sampled_rows:
Expand All @@ -550,6 +557,8 @@ def _ocp_prefixed_tokens(text: str) -> str:
oc_openings = _ocp_prefixed_tokens(opening) if opening else ""
tags_str = " ".join(x for x in [oc_themes, oc_openings] if x).strip()

confidence = _format_confidence(row.get('_quality'))

vals = [
safe_str(row['PuzzleId']),
adj_fen,
Expand All @@ -559,6 +568,7 @@ def _ocp_prefixed_tokens(text: str) -> str:
themes,
opening,
safe_str("theme-solarized"),
confidence,
tags_str
]
puzzle_file.write(",".join([v.replace(',', ';') for v in vals]) + "\n")
Expand Down
3 changes: 3 additions & 0 deletions templates/back.html
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ <h1 class="clean-pill">{{Themes}}</h1>
{{#Opening}}
<div class="clean-pill">{{Opening}}</div>
{{/Opening}}
{{#Confidence}}
<div class="clean-pill" title="Bayesian quality score (Popularity + NbPlays)">★ {{Confidence}}</div>
{{/Confidence}}
</div>
</div>

Expand Down
46 changes: 36 additions & 10 deletions tests/build_apkg_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,15 +193,15 @@ def test_rating_range_and_average(self):
desc = _build_description(rows)
assert "1000" in desc
assert "1200" in desc
assert "average 1100" in desc
assert "avg 1100" in desc

def test_popularity_average(self):
rows = [
_make_themed_row("p1", "fork", popularity="80"),
_make_themed_row("p2", "fork", popularity="100"),
]
desc = _build_description(rows)
assert "average 90%" in desc
assert "Popularity avg 90%" in desc

def test_themes_listed_by_frequency(self):
rows = [
Expand All @@ -211,9 +211,16 @@ def test_themes_listed_by_frequency(self):
_make_themed_row("p4", "fork"),
]
desc = _build_description(rows)
assert "fork (3)" in desc
assert "pin (2)" in desc
assert desc.index("fork (3)") < desc.index("pin (2)") # fork first (more frequent)
assert "fork: 3 puzzles" in desc
assert "pin: 2 puzzles" in desc
assert desc.index("fork: 3 puzzles") < desc.index("pin: 2 puzzles") # fork first (more frequent)

def test_mastery_sentence_present(self):
rows = [_make_themed_row(f"p{i}", "fork") for i in range(40)]
desc = _build_description(rows)
# 40 puzzles at ~20/day → ~2 days
assert "Woodpecker plan" in desc
assert "2 days" in desc

def test_theme_count_in_description(self):
rows = [_make_themed_row("p1", "fork pin skewer")]
Expand Down Expand Up @@ -247,20 +254,39 @@ def test_sample_cards_have_description(self):

def test_coverage_shown_when_provided(self):
rows = [_make_themed_row("p1", "fork pin")]
desc = _build_description(rows, coverage=74.3)
stats = {
"unique_themes_sample": 23,
"unique_motifs_sample": 18,
"unique_themes_tranche": 31,
"unique_motifs_tranche": 24,
"coverage_pct": 74.3,
"coverage_pct_all": 65.0,
}
desc = _build_description(rows, stats=stats)
assert "74.3%" in desc
assert "of tranche themes" in desc
assert "Unique themes covered: 23 (motifs: 18)" in desc
assert "Distinct themes in tranche: 31 (motifs: 24)" in desc
assert "all-theme coverage: 65.0%" in desc

def test_coverage_absent_when_none(self):
rows = [_make_themed_row("p1", "fork pin")]
desc = _build_description(rows, coverage=None)
desc = _build_description(rows, stats=None)
assert "tranche" not in desc
assert "Coverage" not in desc

def test_coverage_100_percent(self):
rows = [_make_themed_row("p1", "fork")]
desc = _build_description(rows, coverage=100.0)
desc = _build_description(rows, stats={"coverage_pct": 100.0})
assert "100.0%" in desc

def test_full_theme_breakdown_listed(self):
"""Every theme appears in the breakdown (not truncated)."""
rows = [_make_themed_row(f"p{i}", "fork") for i in range(3)]
rows.append(_make_themed_row("rare", "enPassant"))
desc = _build_description(rows)
assert "fork: 3 puzzles" in desc
assert "enPassant: 1 puzzle" in desc


class TestLoadDeckStats:
def test_returns_empty_dict_when_file_absent(self, tmp_path):
Expand Down Expand Up @@ -323,7 +349,7 @@ def test_deck_stats_param_produces_apkg(self, tmp_path):
desc = _build_description(
[{"PuzzleID": "t1", "Themes": "fork", "Rating": "1200", "Popularity": "90",
"FEN": "", "Moves": "", "Opening": "", "Display Theme": "", "Tags": ""}],
coverage=88.5,
stats={"coverage_pct": 88.5},
)
assert "88.5%" in desc

Expand Down
Loading