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
41 changes: 41 additions & 0 deletions decisions/2026-06-07-autoplay-seed-similarity-spine.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
72 changes: 72 additions & 0 deletions packages/bot/src/utils/music/autoplay/candidateScorer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
27 changes: 25 additions & 2 deletions packages/bot/src/utils/music/autoplay/candidateScorer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,16 @@ export interface ScoringContext {
dislikedWeights?: Map<string, number>
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[]
Expand All @@ -200,6 +210,7 @@ export function calculateRecommendationScore(ctx: ScoringContext): {
dislikedWeights = new Map(),
sessionMood = null,
skipNoveltyBoost = false,
seedDerived = false,
genreContext = {},
} = ctx
const candidateTags = genreContext.candidateTags ?? []
Expand Down Expand Up @@ -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: [] }
}
}
}
}
Expand All @@ -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')
}

Expand Down
2 changes: 2 additions & 0 deletions packages/bot/src/utils/music/autoplay/lastFmSeeder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ export async function collectLastFmCandidates(
dislikedWeights: ctx.dislikedWeights,
sessionMood: ctx.sessionMood,
skipNoveltyBoost: true,
seedDerived: true,
genreContext: {
candidateTags: tags,
currentTrackTags,
Expand Down Expand Up @@ -174,6 +175,7 @@ export async function collectLastFmCandidates(
dislikedWeights: ctx.dislikedWeights,
sessionMood: ctx.sessionMood,
skipNoveltyBoost: true,
seedDerived: true,
genreContext: {
candidateTags: tags,
currentTrackTags,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading