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 @@