From f0a6d5e6964ead7c01a17566fb79007327569efe Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Mon, 29 Jun 2026 16:03:16 -0700 Subject: [PATCH] =?UTF-8?q?discovery:=20add=20apply=5Fadventurousness=20?= =?UTF-8?q?=E2=80=94=20pure=20popularity-penalty=20re-rank=20(aurral-style?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both Discover rec rows already exclude what you own/watch, so novelty is baked in; the missing lever is a popularity penalty. apply_adventurousness(items, level) re-ranks dicts (score + optional 0-100 popularity) so globally-popular candidates sink as the dial rises. Pure + reusable across both rec rows. level<=0 returns the input order unchanged (a copy) — fully additive, no regression; 1.0 applies the full penalty (a popularity-100 pick loses 70% of its score). Missing popularity is never penalised. 5 seam tests (no-op+copy, demotion, proportional penalty, missing-pop, clamping). 37 pass. Wiring (scan stores popularity -> routes re-rank live -> Settings slider) is the next increment. --- core/discovery/listening_recommendations.py | 48 +++++++++++++++++++ .../test_listening_recommendations.py | 38 +++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/core/discovery/listening_recommendations.py b/core/discovery/listening_recommendations.py index 8769dbb5..eff1a8af 100644 --- a/core/discovery/listening_recommendations.py +++ b/core/discovery/listening_recommendations.py @@ -39,6 +39,14 @@ def _positive_float(value: object, default: float = 1.0) -> float: return f if f > 0 else default +def _coerce_float(value: object, default: float = 0.0) -> float: + """Plain float coercion that keeps 0 / negatives (unlike _positive_float) — popularity can be 0.""" + try: + return float(value) # type: ignore[arg-type] + except (TypeError, ValueError): + return default + + def _get(row: object, attr: str): """Read a field from a dataclass row or a dict row.""" if isinstance(row, dict): @@ -234,6 +242,46 @@ def rank_recommended_artists( return out[:limit] +# ── Adventurousness re-rank (aurral-style popularity penalty) ──────────────────────────────── +# Both Discover rec rows already EXCLUDE what you own / watch, so "novelty" is baked in; the lever we +# were missing is a POPULARITY PENALTY. At higher adventurousness, globally-popular candidates are +# pushed down so the obscure / non-obvious picks surface. Pure + reusable across both rec rows. +_MAX_POP_PENALTY = 0.7 # at level 1.0 a popularity-100 candidate loses 70% of its score + + +def apply_adventurousness( + items: Sequence[dict], + level: object, + *, + score_key: str = "score", + pop_key: str = "popularity", + tiebreak_key: str = "seed_count", +) -> List[dict]: + """Re-rank ``items`` (dicts with a numeric score + an optional 0–100 popularity) by an + adventurousness-scaled popularity penalty. Returns a NEW list, most-adventurous first. + + ``level`` is clamped to 0..1. At ``level <= 0`` the input order is returned **unchanged** (a + copy), so the feature is fully additive / no-regression. Items missing a popularity are never + penalised. Adjusted score = ``score × (1 − level × MAX_POP_PENALTY × popularity/100)``. + """ + lvl = max(0.0, min(1.0, _coerce_float(level, 0.0))) + if lvl <= 0.0: + return list(items) + + def _adjusted(it: object) -> float: + score = _coerce_float(_get(it, score_key), 0.0) + pop = _get(it, pop_key) + if pop is None: + return score + pop_norm = max(0.0, min(1.0, _coerce_float(pop, 0.0) / 100.0)) + return score * (1.0 - lvl * _MAX_POP_PENALTY * pop_norm) + + return sorted( + items, + key=lambda it: (-_adjusted(it), -_coerce_float(_get(it, tiebreak_key), 0.0), _norm(_get(it, "name"))), + ) + + def aggregate_candidate_tracks( recommended_artists: Sequence[RecommendedArtist], top_tracks_by_artist: Dict[str, Sequence[dict]], diff --git a/tests/discovery/test_listening_recommendations.py b/tests/discovery/test_listening_recommendations.py index d323593a..147b3788 100644 --- a/tests/discovery/test_listening_recommendations.py +++ b/tests/discovery/test_listening_recommendations.py @@ -4,6 +4,7 @@ from __future__ import annotations from core.discovery.listening_recommendations import ( aggregate_candidate_tracks, + apply_adventurousness, build_recency_weighted_seeds, choose_mix_fetch_source, names_match, @@ -314,3 +315,40 @@ def test_rank_threading_changes_winner_within_a_seed(): ranked = rank_recommended_artists(seeds, grouped) assert [r.name for r in ranked] == ["Close", "Far"] assert ranked[0].score > ranked[1].score + + +# ── apply_adventurousness (aurral-style popularity-penalty re-rank) ─────────── +def test_adventurousness_zero_is_noop_but_copies(): + items = [{"name": "A", "score": 5.0, "popularity": 90}, + {"name": "B", "score": 4.0, "popularity": 10}] + out = apply_adventurousness(items, 0.0) + assert [i["name"] for i in out] == ["A", "B"] # order unchanged + assert out == items # same content + assert out is not items # but a fresh list (additive) + + +def test_adventurousness_demotes_the_popular_one(): + # Same score; at full adventurousness the obscure pick (pop 10) overtakes the giant (pop 95). + items = [{"name": "Giant", "score": 5.0, "popularity": 95}, + {"name": "Obscure", "score": 5.0, "popularity": 10}] + assert [i["name"] for i in apply_adventurousness(items, 1.0)] == ["Obscure", "Giant"] + + +def test_adventurousness_penalty_is_proportional_not_absolute(): + # A much stronger score still wins despite being more popular — the penalty scales the score. + items = [{"name": "StrongPopular", "score": 10.0, "popularity": 80}, + {"name": "WeakObscure", "score": 1.0, "popularity": 0}] + assert apply_adventurousness(items, 0.5)[0]["name"] == "StrongPopular" + + +def test_adventurousness_missing_popularity_is_unpenalized(): + items = [{"name": "Popular", "score": 5.0, "popularity": 100}, + {"name": "NoPop", "score": 5.0}] + assert apply_adventurousness(items, 1.0)[0]["name"] == "NoPop" + + +def test_adventurousness_clamps_level(): + items = [{"name": "A", "score": 5.0, "popularity": 100}, + {"name": "B", "score": 5.0, "popularity": 0}] + assert [i["name"] for i in apply_adventurousness(items, 5.0)] == ["B", "A"] # >1 clamps to 1 + assert [i["name"] for i in apply_adventurousness(items, -2.0)] == ["A", "B"] # <0 clamps to 0 (no-op)