From 77e5a4982befc251ec6363eebc1a8971ec1e4ceb Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Thu, 2 Jul 2026 06:31:19 -0500 Subject: [PATCH] =?UTF-8?q?v3=20library:=20growth-edge=20"practice=20next"?= =?UTF-8?q?=20recommender=20=E2=80=94=20P3=20(#704)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * v3 library: growth-edge "practice next" recommender — P3 The "Keep practicing" shelf stops being recency-only: a new GET /api/library/practice-suggestions ranks started-but-unmastered songs by difficulty-appropriateness x mastery-proximity (the growth edge - the mid-difficulty, closest-to-mastery material where practice pays off fastest), and the shelf sources it instead of filtering /api/stats/recent. - Score = difficulty band fit (your 1-5 rating; unrated degrades to the middle band so the shelf works before any ratings exist) x proximity to the 0.9 mastery threshold. Read-only - never writes difficulty. - A shelf click opens the closest-to-mastery arrangement. - Per-arrangement difficulty and seed-from-authored intentionally NOT faked: there is no authored/derived difficulty on songs yet (the feedpak difficulty spec is unmerged) and the personal rating is per-song - both revisit when that field lands. 9 endpoint tests. tailwind.min.css regenerated (generated file - on a merge conflict, re-run scripts/build-tailwind.sh). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN * fix(v3): deterministic tiebreak (filename) in practice-next ordering (PR #704 review) Add r["filename"] as the final sort component so suggestions with equal growth_score and equal/None last_played_at order deterministically instead of by SQLite's unordered agg.items() scan. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Fable 5 Co-authored-by: byrongamatos --- server.py | 103 +++++++++++++++++++++ static/v3/songs.js | 49 +++++----- tests/test_practice_suggestions.py | 140 +++++++++++++++++++++++++++++ 3 files changed, 263 insertions(+), 29 deletions(-) create mode 100644 tests/test_practice_suggestions.py diff --git a/server.py b/server.py index 76da63e..0cf3584 100644 --- a/server.py +++ b/server.py @@ -501,6 +501,13 @@ def next_library_cursor(sort: str, last_song: dict | None) -> str | None: return _encode_cursor([last_song[key], last_song["filename"]]) +# Song-level "mastered" threshold — best accuracy across a song's arrangements +# at/above this counts as in your repertoire. One number shared by the green +# accuracy badge, the Repertoire meter, the mastery filter/sort, and the P3 +# growth-edge recommender (matches the frontend MASTERY_ACCURACY). +MASTERY_ACCURACY = 0.9 + + class MetadataDB: def __init__(self): CONFIG_DIR.mkdir(parents=True, exist_ok=True) @@ -1661,6 +1668,78 @@ class MetadataDB: for r in rows ] + # ── FUTURE ENHANCEMENT (revisit once the feedpak difficulty spec is locked) ── + # The library-metadata design (§8) calls for user-difficulty to be + # PER-ARRANGEMENT ("easy on bass ≠ easy on lead") and SEEDED FROM the authored/ + # derived difficulty so it's never blank. Neither ships here on purpose: + # • personal difficulty is currently per-FILENAME (P1's song_user_meta); + # per-arrangement is a P1-schema + Details-drawer (P2) re-scope; and + # • there is NO authored/derived difficulty field on `songs` yet — that waits + # on the feedpak difficulty spec (the #37-family FEP), which is unmerged. + # So this recommender ships the growth-edge PAYOFF now and degrades gracefully + # (an unrated song is treated as mid). When the feedpak difficulty field lands, + # revisit: (1) seed unset user-difficulty from authored instead of assuming mid, + # and (2) score per (filename, arrangement) rather than per song. + @staticmethod + def _growth_edge_score(best_accuracy: float, user_difficulty) -> float: + """The 'practice next' score = difficulty-appropriateness × proximity to + mastery. Peaks where a song is BOTH at a productive challenge level (the + mid difficulty band) AND close to — but not yet at — mastery (the + goal-gradient push). An UNSET personal difficulty is treated as mid, so + the recommender still works before anything is rated (it degrades to + closest-to-mastery-first) — see P3 notes: authored/derived difficulty + seeding waits on the feedpak difficulty spec. + + diff_weight: 3 → 1.0, 2/4 → 0.8, 1/5 → 0.6 (extremes deprioritized, never + zeroed — you grow on the challenging middle, not the trivially easy or the + frustratingly hard). Never writes anything.""" + d = user_difficulty if user_difficulty is not None else 3 + weight = 1.0 - abs(d - 3) * 0.2 + return weight * (best_accuracy or 0.0) + + def growth_edge_suggestions(self, limit: int = 8) -> list[dict]: + """Attempted-but-not-yet-mastered songs ranked by the growth-edge score — + the 'Keep practicing' recommender that replaces recency-only ordering. + Song-level (best accuracy across arrangements, like the badge); the + suggested `arrangement` is the one you're closest to mastering, so the + shelf opens the version worth pushing. Read-only.""" + limit = max(1, min(24, int(limit))) + rows = self.conn.execute( + "SELECT filename, arrangement, best_accuracy, plays, last_played_at " + "FROM song_stats WHERE 1=1 " + self._existing_song_filter() + ).fetchall() + # Aggregate per song: best accuracy + the arrangement that owns it, total + # plays, most-recent play (used as a stable tiebreak). + agg: dict = {} + for fn, arr, acc, plays, lp in rows: + a = agg.get(fn) + if a is None: + a = agg[fn] = {"acc": None, "arr": 0, "plays": 0, "lp": None} + a["plays"] += (plays or 0) + if acc is not None and (a["acc"] is None or acc > a["acc"]): + a["acc"] = acc + a["arr"] = arr + if lp and (not a["lp"] or lp > a["lp"]): + a["lp"] = lp + cands = [(fn, a) for fn, a in agg.items() + if a["plays"] > 0 and a["acc"] is not None and a["acc"] < MASTERY_ACCURACY] + if not cands: + return [] + diffs = self.user_meta_map([fn for fn, _ in cands]) # {filename: 1..5} + out = [] + for fn, a in cands: + d = diffs.get(fn) + out.append({ + "filename": fn, + "best_accuracy": a["acc"], + "arrangement": a["arr"], + "last_played_at": a["lp"], + "user_difficulty": d, + "growth_score": round(self._growth_edge_score(a["acc"], d), 6), + }) + out.sort(key=lambda r: (r["growth_score"], r["last_played_at"] or "", r["filename"]), reverse=True) + return out[:limit] + # ── Playlists ─────────────────────────────────────────────────────────-- SAVED_KEY = "saved_for_later" @@ -6076,6 +6155,30 @@ def api_top_stats(limit: int = 5): return out +@app.get("/api/library/practice-suggestions") +def api_practice_suggestions(limit: int = 8): + """Growth-edge 'practice next' shelf (P3): attempted-but-not-mastered songs + ranked by difficulty-appropriateness × mastery-proximity, joined to song + metadata. Replaces the recency-only 'Keep practicing' shelf ordering. Local + library only — reads local practice stats.""" + from urllib.parse import quote + out = [] + for r in meta_db.growth_edge_suggestions(limit): + meta = meta_db.conn.execute( + "SELECT title, artist, tuning_name FROM songs WHERE filename = ?", + (r["filename"],), + ).fetchone() + title, artist, tuning_name = meta if meta else (None, None, None) + out.append({ + **r, + "title": title or r["filename"], + "artist": artist or "", + "tuning_name": tuning_name or "", + "art_url": f"/api/song/{quote(r['filename'])}/art", + }) + return out + + @app.get("/api/stats/{filename:path}") def api_song_stats(filename: str): return meta_db.get_song_stats(filename) diff --git a/static/v3/songs.js b/static/v3/songs.js index dd1ac0d..5b649fa 100644 --- a/static/v3/songs.js +++ b/static/v3/songs.js @@ -505,12 +505,12 @@ } // ── Practice-aware library home (repertoire meter + "Keep practicing") ───── - // Both read data we already have: state.accuracy (/api/stats/best = - // {filename: best_accuracy}) and /api/stats/recent. A song is "in your - // repertoire" at the same threshold the green accuracy badge uses (>= 0.9); - // a started song below that is "in progress". This is descriptive - // encouragement — it never gates content, decays, or nags (the goal-gradient - // / endowed-progress idea, kept healthy). + // The meter reads state.accuracy (/api/stats/best = {filename: best_accuracy}); + // the shelf reads the growth-edge recommender (/api/library/practice-suggestions, + // P3). A song is "in your repertoire" at the same threshold the green accuracy + // badge uses (>= 0.9); a started song below that is "in progress". This is + // descriptive encouragement — it never gates content, decays, or nags (the + // goal-gradient / endowed-progress idea, kept healthy). const MASTERY_ACCURACY = 0.9; function _repertoireCounts() { @@ -525,9 +525,9 @@ // The home block is the unfiltered "front door": shown on the grid view when // the user isn't running a focused query (search / filter) or selecting. // Local provider only — the meter's mastered count and the shelf both read - // local practice stats (state.accuracy / /api/stats/recent), so on a remote - // provider they'd mix local numerators with a remote song total and play - // local files while browsing a remote library. Hide it there. + // local practice stats (state.accuracy / the practice-suggestions endpoint), + // so on a remote provider they'd mix local numerators with a remote song total + // and play local files while browsing a remote library. Hide it there. function libHomeVisible() { return state.view === 'grid' && state.provider === 'local' && !state.selectMode && !state.q && activeFilterCount() === 0; @@ -543,10 +543,10 @@ const myToken = ++_homeToken; // Unfiltered library size for the meter denominator (the grid's // state.total tracks the active filter; the meter is library-wide) + - // recently-played rows for the shelf, fetched together. - const [stats, recent] = await Promise.all([ + // the growth-edge "practice next" shelf rows, fetched together. + const [stats, suggestions] = await Promise.all([ jget('/api/library/stats?provider=' + enc(state.provider)), - jget('/api/stats/recent?limit=24'), + jget('/api/library/practice-suggestions?limit=8'), ]); if (_homeToken !== myToken || !libHomeVisible()) { // changed mid-fetch if (_homeToken === myToken) host.classList.add('hidden'); @@ -554,22 +554,13 @@ } const total = (stats && (stats.total_songs ?? stats.total)) || 0; if (total <= 0) { host.classList.add('hidden'); return; } // empty library - // Shelf = recently-played, not-yet-mastered songs, newest first. Mastery - // is per-SONG (state.accuracy = MAX best across arrangements, what the - // green badge shows) — recents are per-(song,arrangement), so dedupe by - // filename and gate on the song's best, keeping the shelf and its badges - // consistent (no green-badged "keep practicing" card, no dupes). - const acc = state.accuracy || {}; - const seen = new Set(); - const shelf = (Array.isArray(recent) ? recent : []) - .filter((r) => { - if (!r || seen.has(r.filename)) return false; - const best = acc[r.filename]; - if (typeof best !== 'number' || best >= MASTERY_ACCURACY) return false; - seen.add(r.filename); - return true; - }) - .slice(0, 8); + // Shelf = the growth-edge recommender (P3): attempted-but-not-mastered + // songs ordered by difficulty-appropriateness × mastery-proximity, so it + // points at the version worth practicing next rather than just the most + // recent. The server already gates (not-mastered) + aggregates per song, + // so the rows are the shelf as-is; each row's `arrangement` is the one + // closest to mastery (what a click should open). + const shelf = Array.isArray(suggestions) ? suggestions : []; const { mastered, learning } = _repertoireCounts(); const pct = Math.max(0, Math.min(100, Math.round((mastered / total) * 100))); @@ -606,7 +597,7 @@ // The home block sits above the grid sizer, so its height shifts where the // window maps in scroll space — repaint the window once it's laid out. if (state.view === 'grid') requestWindowRender(); - // Wire shelf cards → play (mirrors playCard's local path; recents are + // Wire shelf cards → play (mirrors playCard's local path; suggestions are // always local-library rows, so no provider sync is needed). host.querySelectorAll('.v3-kp-card').forEach((btn) => btn.addEventListener('click', () => { const fn = btn.getAttribute('data-kp'); diff --git a/tests/test_practice_suggestions.py b/tests/test_practice_suggestions.py new file mode 100644 index 0000000..a858725 --- /dev/null +++ b/tests/test_practice_suggestions.py @@ -0,0 +1,140 @@ +"""Tests for the P3 growth-edge 'practice next' recommender — +GET /api/library/practice-suggestions + meta_db.growth_edge_suggestions(). + +The score is difficulty-appropriateness (mid band wins) × mastery-proximity +(closer to 0.9, not yet there). Read-only: it must never write difficulty. +Personal difficulty is per-filename (P1); authored/derived seeding + true +per-arrangement difficulty are deferred pending the feedpak difficulty spec.""" + +import importlib +import sys + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture() +def server(tmp_path, monkeypatch, isolate_logging): + monkeypatch.setenv("CONFIG_DIR", str(tmp_path)) + monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1") + sys.modules.pop("server", None) + srv = importlib.import_module("server") + try: + yield srv + finally: + conn = getattr(getattr(srv, "meta_db", None), "conn", None) + if conn is not None: + conn.close() + sys.modules.pop("server", None) + + +@pytest.fixture() +def client(server): + return TestClient(server.app) + + +def _seed(server, fn, title=None): + server.meta_db.put(fn, 0, 0, {"title": title or fn.split(".")[0], "artist": "A"}) + + +def _play(server, fn, acc, arr=0): + """Record a scored attempt so the song has a best_accuracy.""" + server.meta_db.record_session(fn, arr, score=int(acc * 1000), accuracy=acc) + + +def _diff(client, fn, d): + client.put(f"/api/song/{fn}/user-meta", json={"user_difficulty": d}) + + +def _suggest(client, limit=8): + return client.get(f"/api/library/practice-suggestions?limit={limit}").json() + + +SUG = "/api/library/practice-suggestions" + + +# ── Gating: only attempted & not-yet-mastered ──────────────────────────────── + +def test_excludes_mastered_and_unattempted(client, server): + _seed(server, "mastered.archive"); _play(server, "mastered.archive", 0.95) + _seed(server, "inprog.archive"); _play(server, "inprog.archive", 0.6) + _seed(server, "fresh.archive") # never played + got = {r["filename"] for r in _suggest(client)} + assert got == {"inprog.archive"} + + +def test_empty_when_nothing_attempted(client, server): + _seed(server, "a.archive") + assert _suggest(client) == [] + + +# ── Ordering: mid-difficulty preferred at equal accuracy ────────────────────── + +def test_mid_difficulty_ranks_above_extremes(client, server): + for fn in ("mid.archive", "easy.archive", "hard.archive"): + _seed(server, fn); _play(server, fn, 0.6) + _diff(client, "mid.archive", 3) + _diff(client, "easy.archive", 1) + _diff(client, "hard.archive", 5) + order = [r["filename"] for r in _suggest(client)] + assert order[0] == "mid.archive" + # 1 and 5 share the same weight, so both trail mid + assert set(order[1:]) == {"easy.archive", "hard.archive"} + + +# ── Ordering: closer-to-mastery preferred at equal difficulty ───────────────── + +def test_closer_to_mastery_ranks_higher(client, server): + _seed(server, "almost.archive"); _play(server, "almost.archive", 0.85) + _seed(server, "early.archive"); _play(server, "early.archive", 0.4) + # both unrated (→ treated as mid), so accuracy proximity decides + order = [r["filename"] for r in _suggest(client)] + assert order == ["almost.archive", "early.archive"] + + +def test_unrated_still_surfaces_as_mid(client, server): + """Before anything is rated the shelf must still work (degrades to + closest-to-mastery). An unrated song outranks a very-easy rated one at + similar accuracy.""" + _seed(server, "unrated.archive"); _play(server, "unrated.archive", 0.7) + _seed(server, "veryeasy.archive"); _play(server, "veryeasy.archive", 0.72) + _diff(client, "veryeasy.archive", 1) + order = [r["filename"] for r in _suggest(client)] + # unrated (weight 1.0 × 0.70 = 0.70) beats very-easy (0.6 × 0.72 = 0.432) + assert order[0] == "unrated.archive" + + +# ── Best arrangement = the one closest to mastery ──────────────────────────── + +def test_suggested_arrangement_is_best(client, server): + _seed(server, "multi.archive") + _play(server, "multi.archive", 0.5, arr=0) + _play(server, "multi.archive", 0.8, arr=1) # closer to mastery + r = _suggest(client)[0] + assert r["filename"] == "multi.archive" + assert r["arrangement"] == 1 + assert r["best_accuracy"] == 0.8 + + +# ── Enrichment + limit ─────────────────────────────────────────────────────── + +def test_rows_are_enriched(client, server): + _seed(server, "song.archive", title="My Song"); _play(server, "song.archive", 0.6) + r = _suggest(client)[0] + assert r["title"] == "My Song" and r["artist"] == "A" + assert r["art_url"].endswith("/art") + assert "growth_score" in r + + +def test_limit_is_respected(client, server): + for i in range(5): + fn = f"s{i}.archive"; _seed(server, fn); _play(server, fn, 0.5 + i * 0.05) + assert len(_suggest(client, limit=3)) == 3 + + +# ── Read-only: never writes difficulty ─────────────────────────────────────── + +def test_recommender_never_writes_difficulty(client, server): + _seed(server, "a.archive"); _play(server, "a.archive", 0.6) + _suggest(client) + assert client.get("/api/song/a.archive/user-meta").json()["user_difficulty"] is None