mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
v3 library: growth-edge "practice next" recommender — P3 (#704)
* 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 <noreply@anthropic.com> 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: byrongamatos <xasiklas@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
byrongamatos
parent
feaaa5cd81
commit
77e5a4982b
@@ -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
|
||||
Reference in New Issue
Block a user