Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions decisions/2026-06-07-autoplay-seed-similarity-spine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# ADR 2026-06-07 — Autoplay: Last.fm seed-similarity spine + fail-closed genre guard

**Status:** Accepted
**Via:** `/research-and-decide` (critic verdict REVISE: A alone insufficient → A+B; no flip on the lead)
**Relates to:** [2026-05-21-autoplay-recommendation-roadmap](2026-05-21-autoplay-recommendation-roadmap.md) (Phase D scoring, deferred)

## Context

Autoplay drifts to mainstream/unrelated music — reported symptom: two Prince tracks in a row
then a jump to Drake. Root cause (verified in code):

1. **Genre guard fails OPEN.** The scorer's cross-genre-family veto (`candidateScorer.ts:251`)
and soft genre penalty (`:423`) both require Last.fm/Spotify _tags_ for seed AND candidate.
With no Last.fm link and no/quota-exhausted Spotify token, `sessionGenreFamilies` and
`currentTrackTags` are empty → both defenses are skipped entirely.
2. **`spotify preferred +0.4`** (`:411`) is the single largest, genre-blind score term — any
Spotify track (incl. mainstream Drake) gets it and outranks more-related candidates.
3. **Spotify `/recommendations` is deprecated** for this app (the team's own `artistApi.ts:117`
comment; Discover already migrated to Last.fm) → the "primary" recommender returns little.
4. **The broad fallback pulls genre TOP-tracks** (`getTagTopTracks`) — mainstream by construction.
5. **The Last.fm collector is entirely user-link-gated** (`lastFmSeeder.ts:75` returns early with
no candidates for unlinked users). The tag-independent, no-link endpoint
`artist.getSimilar` (`getSimilarArtists`, shared `artistApi.ts:89`) exists but has **zero
callers in autoplay**.

So for the common case (user hasn't linked Last.fm, Spotify recs dead), the candidate pool is
just Spotify-search-on-seed + genre-top-tracks fallback, with genre guards disabled → drift.

## Decision

**Adopt A + B now (A first), C as a fast-follow if drift persists.** A is the missing
foundation; B stops the fail-open + genre-blind boost; the critic confirmed A alone is
insufficient because B is what blocks drift when A's pool is thin.

**A — Seed-similarity spine (foundation).** Wire Last.fm `track.getSimilar` (via
`getSimilarTracks`, `lastFmApi.ts:486`; no user link, `LASTFM_API_KEY` is set) on the
**currently-playing seed track** into the candidate collector as a backbone source, boosted as
"seed-similar". (Implementation refined the original `artist.getSimilar` plan to
`track.getSimilar` — it returns playable track candidates directly and reuses existing bot
infra, avoiding a shared-package ESM-exports change.) Demote the deprecated Spotify-recs source
to a best-effort extra (leave as a no-op-tolerant call; do not remove yet).

- Guardrails: wrap in a 2s timeout + per-artist cache (reuse/extend `artistTagCache`, ~1h TTL)
- 429 backoff. Cap to ~10 similar artists, one search each, to bound Last.fm calls (~5 req/s
limit) and replenish latency.

**B — Fail genre guard CLOSED for strong-genre seeds + condition the Spotify boost.**

- Infer the seed's family from `sessionGenreFamilies` (session history), **not just the current
track's tags**, so an untagged current track still carries the session's genre.
- When the session has a **strong** family (rap_hiphop / rock_metal / latin — extend to the
Prince case via the soul/funk/pop families as data shows) and a candidate is **untagged**,
penalize/reject as assumed cross-genre — but only outside skip-storms.
- Make `spotify preferred` **genre-conditional**: full boost only when the candidate's family
overlaps the session; **half** boost when tags are unknown (not zero — avoids starving the
pool); no boost on a known cross-family candidate.

**C — Fast-follow (only if A+B leave residual drift).** Replace the fallback's
`getTagTopTracks` with seed-similar tracks (same `track.getSimilar` as A) so even the last
resort stays on-genre.

**Non-negotiable guardrail across all:** autoplay must never stall. Keep the skip-storm
relaxation; enforce a minimum-pool floor — if A+B reject everything, fall through to the
(now seed-similar, post-C) fallback rather than returning an empty queue.

## Alternatives considered

- **A alone** — rejected (critic): fixes the thin-pool case but leaves the genre guard failing
open, so a mainstream Spotify track still outranks seed-similar ones. B is required.
- **Fail genre guard closed everywhere / unconditionally** — rejected: empty-queue/stall risk
(worse than occasional drift). Hence "strong family + untagged + not-skip-storm" scoping and
the half-boost-on-unknown compromise.
- **Just fix Spotify seed resolution** (make all seeds Spotify-native so recs get good input) —
rejected: the recs endpoint is deprecated for this app; better input can't fix a dead endpoint.
- **Jump straight to a Phase-D session-coherence scorer** — deferred: heavier, and A+B should
resolve the reported symptom; measure first (telemetry already ships) before that investment.

## Consequences

- **Positive:** autoplay grounds on seed-artist similarity regardless of user linking or Spotify
health → "continuous radio" near the seed; the largest drift vector (genre-blind Spotify boost
on a tagless session) is closed.
- **Negative:** +1–2 Last.fm calls per replenish (mitigated by cache/timeout/cap); a tuning risk
on B (over-filter → thin pool), bounded by skip-storm relaxation + the pool floor + half-boost.
- **Neutral:** the deprecated Spotify-recs source stays as tolerated dead weight until a separate
cleanup; Phase D remains deferred.

## Revisit when

- **~2 weeks post-deploy, read the recommendation telemetry** (per-source acceptance / skip
rate): >10% acceptance improvement → done, keep Phase D deferred; 5–10% → ship C, reassess;
**<5% or drift still reported** → escalate to the Phase-D session-coherence scorer.
- **Last.fm error rate >5% or p99 replenish latency regresses** → tighten cache/timeout or make
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.
108 changes: 108 additions & 0 deletions packages/bot/src/utils/music/autoplay/candidateScorer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,114 @@ describe('candidateScorer', () => {
})
expect(result.score).toBeGreaterThan(1)
})

describe('genre-conditional spotify-preferred boost (approach B)', () => {
const session = new Set(['rnb_soul', 'rock_metal'])

it('gives the full boost when the candidate overlaps the session family', () => {
const result = calculateRecommendationScore({
candidate: createTrack({
author: 'In Genre',
source: 'spotify',
}),
currentTrack: createTrack({ author: 'Seed' }),
recentArtists: new Set(),
genreContext: {
candidateTags: ['soul'],
currentTrackTags: ['rock'],
sessionGenreFamilies: session,
},
})
expect(result.signals).toContain('spotify preferred')
})

it('drops the boost entirely for a known cross-family candidate (Drake on a Prince session)', () => {
const withBoost = calculateRecommendationScore({
candidate: createTrack({
author: 'Drake',
source: 'spotify',
}),
currentTrack: createTrack({ author: 'Prince' }),
recentArtists: new Set(),
genreContext: {
candidateTags: ['hip hop'],
currentTrackTags: ['soul'],
sessionGenreFamilies: session,
},
})
expect(withBoost.signals).not.toContain('spotify preferred')
})

it('halves (not drops) the boost when the candidate genre is unknown', () => {
// Non-strong session so the untagged candidate isn't also hit
// by the strong-family fail-closed guard — isolates the boost.
const softSession = new Set(['rnb_soul', 'pop'])
const onGenre = calculateRecommendationScore({
candidate: createTrack({
author: 'In Genre',
source: 'spotify',
}),
currentTrack: createTrack({ author: 'Seed' }),
recentArtists: new Set(),
genreContext: {
candidateTags: ['soul'],
currentTrackTags: ['soul'],
sessionGenreFamilies: softSession,
},
})
const unknown = calculateRecommendationScore({
candidate: createTrack({
author: 'Unknown Genre',
source: 'spotify',
}),
currentTrack: createTrack({ author: 'Seed' }),
recentArtists: new Set(),
genreContext: {
candidateTags: [],
currentTrackTags: ['soul'],
sessionGenreFamilies: softSession,
},
})
// Same author/title, so only the spotify-boost term differs:
// full (0.4) vs half (0.2) → a 0.2 gap, and the signal stays.
expect(unknown.signals).toContain('spotify preferred')
expect(onGenre.score - unknown.score).toBeCloseTo(0.2, 5)
})
})

it('fails closed on an untagged candidate in a strong-family session (approach B)', () => {
const result = 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']),
},
})
expect(result.signals).toContain('genre family drift')
})

it('does not fail closed for untagged candidates on a non-strong session', () => {
const result = calculateRecommendationScore({
candidate: createTrack({
author: 'Untagged',
source: 'youtube',
}),
currentTrack: createTrack({ author: 'Popstar' }),
recentArtists: new Set(),
genreContext: {
candidateTags: [],
currentTrackTags: ['pop'],
sessionGenreFamilies: new Set(['pop']),
},
})
expect(result.signals).not.toContain('genre family drift')
})
})

describe('calculateGenreFamilyPenalty', () => {
Expand Down
63 changes: 59 additions & 4 deletions packages/bot/src/utils/music/autoplay/candidateScorer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@
const GENRE_PENALTY_WEAK = -0.3
const GENRE_PENALTY_UNKNOWN = -0.1
const SCORE_SPOTIFY_PREFERRED = 0.4
// On an unknown-genre candidate the spotify-preferred boost is halved rather
// than dropped, so the pool isn't starved when tags are missing.
const SPOTIFY_PREFERRED_UNKNOWN_MULTIPLIER = 0.5
// Genre families dense/cohesive enough that a cross-family jump reads as drift.
// Used both for the cross-family penalty and the untagged-candidate fail-closed
// guard. Kept deliberately narrow (pop/soul are too broad to fail closed on).
const STRONG_GENRE_FAMILIES = ['rap_hiphop', 'rock_metal', 'latin']

Check warning on line 51 in packages/bot/src/utils/music/autoplay/candidateScorer.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

`STRONG_GENRE_FAMILIES` should be a `Set`, and use `STRONG_GENRE_FAMILIES.has()` to check existence or non-existence.

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_Lucky&issues=AZ6kPmTTWneQtRL6ud8Y&open=AZ6kPmTTWneQtRL6ud8Y&pullRequest=1268
const DISLIKE_WEIGHT_THRESHOLD = 0.5
const POPULAR_TRACK_THRESHOLD = 50

Expand Down Expand Up @@ -145,9 +152,8 @@
}
}

const strongGenres = ['rap_hiphop', 'rock_metal', 'latin']
const isStrongGenre = Array.from(currentFamilies).some((f) =>
strongGenres.includes(f),
STRONG_GENRE_FAMILIES.includes(f),
)

return isStrongGenre ? GENRE_PENALTY_STRONG : GENRE_PENALTY_WEAK
Expand Down Expand Up @@ -200,6 +206,17 @@
const currentTrackTags = genreContext.currentTrackTags ?? []
const sessionGenreFamilies =
genreContext.sessionGenreFamilies ?? new Set<string>()
// Reference families for the session: prefer the history-derived session
// families (present even when the current track is untagged), else fall
// back to the current track's own tags. Drives both the genre-conditional
// spotify boost and the untagged fail-closed guard below.
const referenceFamilies =
sessionGenreFamilies.size > 0
? sessionGenreFamilies
: getGenreFamilies(currentTrackTags)
const sessionHasStrongFamily = Array.from(referenceFamilies).some((f) =>
STRONG_GENRE_FAMILIES.includes(f),
)
const currentArtist = currentTrack.author.toLowerCase()
const candidateArtist = candidate.author.toLowerCase()
const candidateArtistKey = normalizeText(cleanAuthor(candidate.author))
Expand Down Expand Up @@ -268,6 +285,18 @@
}
}

// Fail-closed for strong-family sessions when the candidate is UNTAGGED.
// The tagged cross-genre veto above can't judge a candidate with no Last.fm
// tags, so on a rap/rock/latin session an unknown mainstream track would
// otherwise slip through (fail-open — the core of the Prince→Drake drift).
// Treat untagged candidates as assumed cross-genre and demote them, but
// 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
signals.push('genre family drift')
}

if (preferredArtistKeys.has(candidateArtistKey)) {
score += SCORE_PREFERRED_ARTIST
signals.push('preferred artist')
Expand Down Expand Up @@ -409,8 +438,34 @@
}

if (candidate.source === 'spotify') {
score += SCORE_SPOTIFY_PREFERRED
signals.push('spotify preferred')
// Genre-condition the spotify-preferred boost — the single largest,
// previously genre-blind term that let mainstream Spotify tracks (e.g.
// Drake on a Prince session) outrank seed-similar candidates. Full
// boost only when the candidate overlaps the session's families; half
// when its genre is unknown (don't starve the pool); none on a known
// cross-family candidate.
const candidateFamilies =
candidateTags.length > 0
? getGenreFamilies(candidateTags)
: new Set<string>()
let spotifyBoost: number
if (referenceFamilies.size === 0 || candidateFamilies.size === 0) {
spotifyBoost =
SCORE_SPOTIFY_PREFERRED * SPOTIFY_PREFERRED_UNKNOWN_MULTIPLIER
} else {
let overlaps = false
for (const family of candidateFamilies) {
if (referenceFamilies.has(family)) {
overlaps = true
break
}
}
spotifyBoost = overlaps ? SCORE_SPOTIFY_PREFERRED : 0
}
if (spotifyBoost > 0) {
score += spotifyBoost
signals.push('spotify preferred')
}
}

// Soft genre-family penalty (formerly inside enrichWithAudioFeatures via
Expand Down
Loading
Loading