From f0611d1e39f28b1cbd96b52b528e9cdf1da82246 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Mon, 8 Jun 2026 12:43:53 -0300 Subject: [PATCH] fix(autoplay): provenance-aware genre guards to open the seed neighborhood MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Autoplay over-narrowed to the seed artist after the genre guards shipped: the candidate pool contains related-but-different artists (track.getSimilar is track-to-track), but the cross-family veto and the untagged fail-closed -0.6 scored them out, leaving the seed artist to win every pass. Make the genre guards provenance-aware via a `seedDerived` flag: candidates from Last.fm-similarity-vetted sources (seed-similar, lastfm-similar, lastfm-loved) define the safe radius and get relaxed guards — the cross-family veto becomes a demotion (adjacent families allowed, ranked lower) and the untagged fail-closed becomes a mild penalty. Un-vetted sources (broad fallback, genre-tag, generic search) keep the strict guards that block mainstream drift, so the original drift fix stays intact. No new Last.fm calls. The artist.getSimilar neighborhood source is deferred (latency + drift-hole risk) — only added if this proves the pool truly lacks related artists. ADR 2026-06-07 addendum 2026-06-08. --- ...26-06-07-autoplay-seed-similarity-spine.md | 41 +++++++++++ .../music/autoplay/candidateScorer.spec.ts | 72 +++++++++++++++++++ .../utils/music/autoplay/candidateScorer.ts | 27 ++++++- .../src/utils/music/autoplay/lastFmSeeder.ts | 2 + .../music/autoplay/seedSimilarityCollector.ts | 3 + 5 files changed, 143 insertions(+), 2 deletions(-) diff --git a/decisions/2026-06-07-autoplay-seed-similarity-spine.md b/decisions/2026-06-07-autoplay-seed-similarity-spine.md index d287981a4..b0e7a9c95 100644 --- a/decisions/2026-06-07-autoplay-seed-similarity-spine.md +++ b/decisions/2026-06-07-autoplay-seed-similarity-spine.md @@ -94,3 +94,44 @@ relaxation; enforce a minimum-pool floor — if A+B reject everything, fall thro the seed-similar fetch fully async (pre-warm next seed's similars). - **Over-filtering surfaces** (empty-queue warnings rise) → relax B's strong-family list / raise the half-boost / widen skip-storm relaxation. + +--- + +## Addendum 2026-06-08 — over-narrowing fix (provenance-aware genre guards) + +**Context.** After A+B shipped (v2.17.0), the reported symptom flipped: autoplay now loops the +**same seed artist** instead of drifting. Root cause (verified in code): the candidate pool DOES +contain related-but-different artists (Last.fm `track.getSimilar` is track-to-track, returns mixed +artists), but approach-B's genre guards score them out — untagged related artists hit the +fail-closed `−0.6` (`GENRE_PENALTY_STRONG`), and tagged adjacent-family related artists hit the +hard cross-family **veto** (`−Infinity`). The seed artist survives (same family, known), so it +wins every pass despite the `MAX_TRACKS_PER_ARTIST=2` cap. It is a **scoring** over-correction, +not a sourcing gap. + +**Decision (via `/research-and-decide`; critic flipped the lead from "new source" to "tune +guards").** Make the genre guards **provenance-aware**: candidates from Last.fm-similarity-vetted +sources (`seed-similar`, `lastfm-similar`, `lastfm-loved`) define the *safe radius* and get a +**relaxed** guard — the untagged fail-closed becomes a mild penalty (`GENRE_PENALTY_UNKNOWN` +−0.1), and the cross-family veto becomes a **demotion** (`GENRE_PENALTY_WEAK` −0.3) rather than a +hard `−Infinity` reject, so adjacent families are allowed-but-ranked-lower. **Un-vetted** sources +(broad fallback, genre-tag, generic Spotify search) keep the **strict** guard (veto + −0.6), so +the original mainstream drift — which came from those un-vetted sources + the genre-blind boost — +stays blocked. The cross-locale (Spanish) veto stays unconditional for all sources. The existing +same-artist penalty (−0.35) + per-artist cap then diversify naturally once alternatives survive. +Mechanism: a `seedDerived` flag on the scorer context, set true by the vetted collectors. + +**Why not the alternatives.** +- *New `artist.getSimilar` neighborhood source* — **deferred** (not rejected): adds 7-9 Last.fm + calls to an already ~10-call fan-out (hits the ~5 req/s limit; 2s timeout becomes a cliff), and + a blanket exemption for "similar artists of a mainstream seed" is a genuine drift hole (critic). + Revisit only if the provenance-aware tuning proves the pool truly lacks related artists. +- *Blanket-soften the fail-closed `−0.6` for everyone* — rejected: re-admits the un-vetted + mainstream drift the guard was built to stop. +- *Same-artist saturation penalty across cycles* — unnecessary once vetted alternatives survive; + the existing −0.35 + per-artist cap suffice. Hold in reserve. + +**Revisit when.** Post-deploy, if autoplay STILL loops the seed artist → `track.getSimilar` is too +same-artist and the pool genuinely lacks related artists → implement the deferred +`artist.getSimilar` neighborhood source (with a high match-threshold + tight radius, NOT a blanket +guard exemption). If mainstream drift RE-APPEARS → the provenance trust is too loose; tighten the +relaxed veto back toward reject for `seed-similar` or add a match-score floor. diff --git a/packages/bot/src/utils/music/autoplay/candidateScorer.spec.ts b/packages/bot/src/utils/music/autoplay/candidateScorer.spec.ts index dcc0c2749..32c0278bb 100644 --- a/packages/bot/src/utils/music/autoplay/candidateScorer.spec.ts +++ b/packages/bot/src/utils/music/autoplay/candidateScorer.spec.ts @@ -303,6 +303,78 @@ describe('candidateScorer', () => { }) expect(result.signals).not.toContain('genre family drift') }) + + describe('safe radius — seedDerived relaxation (2026-06-08 addendum)', () => { + it('demotes (not rejects) a seed-derived cross-family candidate', () => { + const result = calculateRecommendationScore({ + candidate: createTrack({ + author: 'Adjacent Genre', + source: 'youtube', + }), + currentTrack: createTrack({ author: 'Rapper' }), + recentArtists: new Set(), + seedDerived: true, + genreContext: { + candidateTags: ['soul'], + currentTrackTags: ['hip hop'], + sessionGenreFamilies: new Set(['rap_hiphop']), + }, + }) + // vetted-related → allowed into the radius, demoted not vetoed + expect(result.score).toBeGreaterThan(-Infinity) + expect(result.signals).toContain('genre family drift') + }) + + it('still hard-rejects an UN-vetted cross-family candidate (drift guard intact)', () => { + const result = calculateRecommendationScore({ + candidate: createTrack({ + author: 'Mainstream', + source: 'youtube', + }), + currentTrack: createTrack({ author: 'Rapper' }), + recentArtists: new Set(), + // seedDerived omitted → un-vetted + genreContext: { + candidateTags: ['soul'], + currentTrackTags: ['hip hop'], + sessionGenreFamilies: new Set(['rap_hiphop']), + }, + }) + expect(result.score).toBe(-Infinity) + }) + + it('applies only a mild penalty to a seed-derived UNTAGGED candidate in a strong session', () => { + const seed = calculateRecommendationScore({ + candidate: createTrack({ + author: 'Untagged Related', + source: 'youtube', + }), + currentTrack: createTrack({ author: 'Rapper' }), + recentArtists: new Set(), + seedDerived: true, + genreContext: { + candidateTags: [], + currentTrackTags: ['hip hop'], + sessionGenreFamilies: new Set(['rap_hiphop']), + }, + }) + const unvetted = calculateRecommendationScore({ + candidate: createTrack({ + author: 'Untagged Mainstream', + source: 'youtube', + }), + currentTrack: createTrack({ author: 'Rapper' }), + recentArtists: new Set(), + genreContext: { + candidateTags: [], + currentTrackTags: ['hip hop'], + sessionGenreFamilies: new Set(['rap_hiphop']), + }, + }) + // seed-derived: -0.1 (GENRE_PENALTY_UNKNOWN); un-vetted: -0.6 → 0.5 gap + expect(seed.score - unvetted.score).toBeCloseTo(0.5, 5) + }) + }) }) describe('calculateGenreFamilyPenalty', () => { diff --git a/packages/bot/src/utils/music/autoplay/candidateScorer.ts b/packages/bot/src/utils/music/autoplay/candidateScorer.ts index 8a1effd9b..8d1438784 100644 --- a/packages/bot/src/utils/music/autoplay/candidateScorer.ts +++ b/packages/bot/src/utils/music/autoplay/candidateScorer.ts @@ -175,6 +175,16 @@ export interface ScoringContext { dislikedWeights?: Map sessionMood?: SessionMood | null skipNoveltyBoost?: boolean + /** + * True when the candidate came from a Last.fm-similarity-vetted source + * (seed-similar / lastfm-similar / lastfm-loved) — i.e. inside the "safe + * radius" around the seed. Such candidates get RELAXED genre guards: the + * cross-family veto becomes a demotion (adjacent families allowed) and the + * untagged fail-closed becomes a mild penalty. Un-vetted sources (broad + * fallback, genre-tag, generic search) keep the strict guards that block + * mainstream drift. See ADR 2026-06-07 addendum 2026-06-08. + */ + seedDerived?: boolean genreContext?: { candidateTags?: string[] currentTrackTags?: string[] @@ -200,6 +210,7 @@ export function calculateRecommendationScore(ctx: ScoringContext): { dislikedWeights = new Map(), sessionMood = null, skipNoveltyBoost = false, + seedDerived = false, genreContext = {}, } = ctx const candidateTags = genreContext.candidateTags ?? [] @@ -280,7 +291,16 @@ export function calculateRecommendationScore(ctx: ScoringContext): { } } if (!intersects) { - return { score: -Infinity, signals: [] } + // Un-vetted cross-family candidate → hard reject (drift guard). + // Seed-derived (Last.fm-similarity-vetted) candidate → it's + // inside the safe radius, so allow an adjacent family but demote + // it rather than reject, keeping the seed neighborhood explorable. + if (seedDerived) { + score += GENRE_PENALTY_WEAK + signals.push('genre family drift') + } else { + return { score: -Infinity, signals: [] } + } } } } @@ -293,7 +313,10 @@ export function calculateRecommendationScore(ctx: ScoringContext): { // keep them in the pool (penalty, not hard reject) so the queue never // stalls; relaxed during skip storms so the pool can broaden. if (!inSkipStorm && candidateTags.length === 0 && sessionHasStrongFamily) { - score += GENRE_PENALTY_STRONG + // Seed-derived untagged candidates are vetted-related to the seed, so a + // mild penalty (don't assume cross-genre); un-vetted untagged candidates + // keep the strong fail-closed penalty that blocks mainstream drift. + score += seedDerived ? GENRE_PENALTY_UNKNOWN : GENRE_PENALTY_STRONG signals.push('genre family drift') } diff --git a/packages/bot/src/utils/music/autoplay/lastFmSeeder.ts b/packages/bot/src/utils/music/autoplay/lastFmSeeder.ts index 193fefe74..9d6d9b4b7 100644 --- a/packages/bot/src/utils/music/autoplay/lastFmSeeder.ts +++ b/packages/bot/src/utils/music/autoplay/lastFmSeeder.ts @@ -114,6 +114,7 @@ export async function collectLastFmCandidates( dislikedWeights: ctx.dislikedWeights, sessionMood: ctx.sessionMood, skipNoveltyBoost: true, + seedDerived: true, genreContext: { candidateTags: tags, currentTrackTags, @@ -174,6 +175,7 @@ export async function collectLastFmCandidates( dislikedWeights: ctx.dislikedWeights, sessionMood: ctx.sessionMood, skipNoveltyBoost: true, + seedDerived: true, genreContext: { candidateTags: tags, currentTrackTags, diff --git a/packages/bot/src/utils/music/autoplay/seedSimilarityCollector.ts b/packages/bot/src/utils/music/autoplay/seedSimilarityCollector.ts index 8653eec83..6d29b10f0 100644 --- a/packages/bot/src/utils/music/autoplay/seedSimilarityCollector.ts +++ b/packages/bot/src/utils/music/autoplay/seedSimilarityCollector.ts @@ -145,6 +145,9 @@ export async function collectSeedSimilarCandidates( dislikedWeights: ctx.dislikedWeights, sessionMood: ctx.sessionMood, skipNoveltyBoost: true, + // Seed-derived: relax the genre guards to the safe radius around + // the seed so related-but-different artists aren't scored out. + seedDerived: true, genreContext: { candidateTags: tags, currentTrackTags,