From ad38d1b6302e0ed961dfd0e69c2e5aba29869f8c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 18:47:39 +0000 Subject: [PATCH] feat: add Confidence field and enrich deck descriptions with selection stats - Write the Bayesian quality score to CSV as a Confidence column and expose it as an Anki field, shown on the card back as a star pill. - Rebuild deck descriptions to mirror the generation-time report: summary line, motif/theme coverage, full theme-frequency breakdown, and a Woodpecker-based mastery-time estimate (~20 new puzzles/day). - Update tests and README accordingly. --- README.md | 3 + build_apkg.py | 92 +++++++++++++++++++++------ lichess_optimized_puzzles_datasets.py | 12 +++- templates/back.html | 3 + tests/build_apkg_test.py | 46 +++++++++++--- 5 files changed, 125 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 5e1ee67..8c8554d 100644 --- a/README.md +++ b/README.md @@ -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! ๐Ÿš€โ™Ÿ๏ธ *** diff --git a/build_apkg.py b/build_apkg.py index 1a8710b..9fb0987 100644 --- a/build_apkg.py +++ b/build_apkg.py @@ -14,6 +14,7 @@ import csv import hashlib import json +import math import os import shutil import zipfile @@ -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]). @@ -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", }, { @@ -97,6 +105,7 @@ def guid(self): "Themes": "pin", "Opening": "Italian", "Display Theme": "theme-solarized", + "Confidence": "0.874", "Tags": "OCP::pin OCP::Italian", }, { @@ -108,6 +117,7 @@ def guid(self): "Themes": "endgame pawnEndgame", "Opening": "", "Display Theme": "theme-solarized", + "Confidence": "0.953", "Tags": "OCP::endgame OCP::pawnEndgame", }, ] @@ -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, ) @@ -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: @@ -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) @@ -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)) diff --git a/lichess_optimized_puzzles_datasets.py b/lichess_optimized_puzzles_datasets.py index 5e1ba07..4ebff42 100644 --- a/lichess_optimized_puzzles_datasets.py +++ b/lichess_optimized_puzzles_datasets.py @@ -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, @@ -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: @@ -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, @@ -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") diff --git a/templates/back.html b/templates/back.html index cb21509..05c8b13 100644 --- a/templates/back.html +++ b/templates/back.html @@ -36,6 +36,9 @@

{{Themes}}

{{#Opening}}
{{Opening}}
{{/Opening}} + {{#Confidence}} +
โ˜… {{Confidence}}
+ {{/Confidence}} diff --git a/tests/build_apkg_test.py b/tests/build_apkg_test.py index af2b513..29c3a78 100644 --- a/tests/build_apkg_test.py +++ b/tests/build_apkg_test.py @@ -193,7 +193,7 @@ 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 = [ @@ -201,7 +201,7 @@ def test_popularity_average(self): _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 = [ @@ -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")] @@ -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): @@ -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