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
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const applyStoredAutoplayPreference =
jest.fn<(queue: unknown, guildId: string) => Promise<void>>()
const blendAutoplayTracks =
jest.fn<(queue: unknown, track: unknown) => Promise<void>>()
const clearSessionMoodCache = jest.fn<(guildId: string) => void>()
const addBreadcrumb = jest.fn()
const errorLog = jest.fn()

Expand All @@ -20,6 +21,9 @@ jest.mock('./autoplayPreference', () => ({
jest.mock('../../../../../utils/music/queueManipulation', () => ({
blendAutoplayTracks: (q: unknown, t: unknown) => blendAutoplayTracks(q, t),
}))
jest.mock('../../../../../utils/music/autoplay/replenisher', () => ({
clearSessionMoodCache: (id: string) => clearSessionMoodCache(id),
}))
jest.mock('@lucky/shared/utils/monitoring', () => ({
addBreadcrumb: (...args: unknown[]) => addBreadcrumb(...args),
}))
Expand Down Expand Up @@ -58,6 +62,7 @@ describe('runPostPlayBackgroundOps', () => {
})

expect(clearAutoplayPause).toHaveBeenCalledWith(guildId)
expect(clearSessionMoodCache).toHaveBeenCalledWith(guildId)
expect(applyStoredAutoplayPreference).toHaveBeenCalledTimes(1)
expect(blendAutoplayTracks).toHaveBeenCalledTimes(1)
expect(failedOps()).toEqual([])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { addBreadcrumb } from '@lucky/shared/utils/monitoring'
import { blendAutoplayTracks } from '../../../../../utils/music/queueManipulation'
import { applyStoredAutoplayPreference } from './autoplayPreference'
import { clearAutoplayPause } from '../../../../../utils/music/autoplay/skipCircuitBreaker'
import { clearSessionMoodCache } from '../../../../../utils/music/autoplay/replenisher'

export interface PostPlayBackgroundOpsInput {
queue: GuildQueue | null | undefined
Expand Down Expand Up @@ -75,6 +76,10 @@ export async function runPostPlayBackgroundOps(
clearAutoplayPause(guildId),
)

await runIsolated('clearSessionMoodCache', guildId, () =>
clearSessionMoodCache(guildId),
)

if (!hadQueueBeforePlay && queue) {
await runIsolated('applyStoredAutoplayPreference', guildId, () =>
withSingleRetry(() =>
Expand Down
1 change: 1 addition & 0 deletions packages/bot/src/utils/music/autoplay/autoplayContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,5 @@ export interface AutoplayContext {
}
replayFrequentTrackIds?: Set<string>
replayFrequentArtists?: Set<string>
recentArtistIndices?: Map<string, number>
}
71 changes: 71 additions & 0 deletions packages/bot/src/utils/music/autoplay/candidateScorer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
enrichWithAudioFeatures,
calculateGenreFamilyPenalty,
getGenreFamilies,
RECENCY_WINDOW_TRACKS,
} from './candidateScorer'
import type { SessionMood } from './sessionMood'

Expand Down Expand Up @@ -749,4 +750,74 @@ describe('candidateScorer', () => {
expect(result.signals).not.toContain('implicit dislike')
})
})

describe('recency-decay penalty (diversity decay)', () => {
const candidateArtist = 'Recent Band'
const candidateKey = candidateArtist.toLowerCase()

function score(recentArtistIndices?: Map<string, number>) {
return calculateRecommendationScore({
candidate: createTrack({ author: candidateArtist }),
currentTrack: createTrack({ author: 'Now Playing Artist' }),
recentArtists: new Set(),
recentArtistIndices,
})
}

it('penalizes a candidate whose artist played most recently (index 0)', () => {
const baseline = score()
const decayed = score(new Map([[candidateKey, 0]]))
expect(decayed.score).toBeLessThan(baseline.score)
expect(decayed.signals).toContain('recency decay')
})

it('applies a smaller penalty the further back the artist appeared', () => {
const recent = score(new Map([[candidateKey, 0]]))
const older = score(new Map([[candidateKey, 8]]))
// Older appearance => smaller decay factor => less-negative penalty
// => higher score than the most-recent case.
expect(older.score).toBeGreaterThan(recent.score)
expect(older.signals).toContain('recency decay')
})

it('applies no penalty once the artist is at or beyond the decay window', () => {
const baseline = score()
const atWindow = score(new Map([[candidateKey, RECENCY_WINDOW_TRACKS]]))
expect(atWindow.score).toBe(baseline.score)
expect(atWindow.signals).not.toContain('recency decay')
})

it('applies no penalty when the candidate artist is not in the recent map', () => {
const result = score(new Map([['some other artist', 0]]))
expect(result.signals).not.toContain('recency decay')
})

it('omits the penalty entirely when no recency map is provided', () => {
const result = score()
expect(result.signals).not.toContain('recency decay')
})

it('is bounded so it cannot flip a genre-family veto (-Infinity)', () => {
const result = calculateRecommendationScore({
candidate: createTrack({ author: candidateArtist }),
currentTrack: createTrack({ author: 'Now Playing Artist' }),
recentArtists: new Set(),
recentArtistIndices: new Map([[candidateKey, 0]]),
autoplayMode: 'similar',
sessionMood: {
dominantLocale: null,
deepDiveArtist: null,
preferLong: false,
preferShort: false,
restless: false,
},
genreContext: {
candidateTags: ['thrash metal'],
currentTrackTags: ['reggaeton'],
sessionGenreFamilies: new Set(['latin']),
},
})
expect(result.score).toBe(-Infinity)
})
})
})
29 changes: 29 additions & 0 deletions packages/bot/src/utils/music/autoplay/candidateScorer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ const SPOTIFY_PREFERRED_UNKNOWN_MULTIPLIER = 0.5
// artist replayed >2 times in the last 30 days. Bounded so it cannot flip
// genre vetoes (-Infinity) or dominate provenance-aware rankings.
const SCORE_REPLAY_COUNT_BOOST = 0.15
// Recency-decay penalty: applied to candidates whose artist appeared in the
// recent queue, scaled by how recently they appeared. Decays linearly to zero
// over a window of RECENCY_WINDOW_TRACKS. Bounded so it cannot flip genre
// vetoes (-Infinity) or override provenance-aware ordering.
const SCORE_RECENCY_DECAY_MAX = -0.15
export const RECENCY_WINDOW_TRACKS = 10
// 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).
Expand Down Expand Up @@ -200,6 +206,12 @@ export interface ScoringContext {
*/
replayFrequentTrackIds?: Set<string>
replayFrequentArtists?: Set<string>
/**
* Map of artist names (lowercased) to their queue position (0 = most recent)
* in the recent history. Used to calculate recency-decay penalty: artists
* that appeared recently get a penalty that decays to zero over RECENCY_WINDOW_TRACKS.
*/
recentArtistIndices?: Map<string, number>
}

export function calculateRecommendationScore(ctx: ScoringContext): {
Expand All @@ -224,6 +236,7 @@ export function calculateRecommendationScore(ctx: ScoringContext): {
genreContext = {},
replayFrequentTrackIds = new Set(),
replayFrequentArtists = new Set(),
recentArtistIndices = new Map(),
} = ctx
const candidateTags = genreContext.candidateTags ?? []
const currentTrackTags = genreContext.currentTrackTags ?? []
Expand Down Expand Up @@ -516,6 +529,22 @@ export function calculateRecommendationScore(ctx: ScoringContext): {
signals.push('replay frequent')
}

// Recency-decay penalty: candidates whose artist appeared in the recent
// queue receive a penalty that decays linearly to zero over a window of
// RECENCY_WINDOW_TRACKS. More recent = larger penalty, older = smaller
// penalty. Bounded and applied additively so it cannot flip hard vetoes
// or override provenance-aware ordering.
const recentIndex = recentArtistIndices.get(candidateArtist)
if (recentIndex !== undefined && recentIndex >= 0) {
// Linear decay: penalty = max * (1 - index/window), clamped to [0, max]
const decayFactor = Math.max(0, 1 - recentIndex / RECENCY_WINDOW_TRACKS)
const penalty = SCORE_RECENCY_DECAY_MAX * decayFactor
if (penalty !== 0) {
score += penalty
signals.push('recency decay')
}
}

// Soft genre-family penalty (formerly inside enrichWithAudioFeatures via
// Spotify's getArtistGenres). When both sides have Last.fm tags, score
// family overlap: same family → 0, no families known → −0.1, no overlap
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export type RecommendationSignal =
| 'discovery boost'
| 'energy match'
| 'replay frequent'
| 'recency decay'

export interface RecommendationBasis {
source: RecommendationSource
Expand Down
35 changes: 35 additions & 0 deletions packages/bot/src/utils/music/autoplay/replenisher.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,41 @@ describe('replenishQueue', () => {
'guildid',
)
})

it('builds recency-decay indices from full history: most-recent-per-artist, dedup, missing-author skip, window-bounded', async () => {
const { collectRecommendationCandidates } = require('./candidateCollector')
collectRecommendationCandidates.mockResolvedValue(new Map())

// allTracks = [currentTrack, ...history]; only positions < window (10)
// are mapped, each unique artist keyed to its most-recent position.
const history = [
createTrack({ author: 'Alpha' }), // allTracks idx 1
createTrack({ author: 'Beta' }), // idx 2
createTrack({ author: 'Alpha' }), // idx 3 — dup, keep idx 1
createTrack({ author: '' }), // idx 4 — missing author, skip
createTrack({ author: 'F5' }), // idx 5
createTrack({ author: 'F6' }), // idx 6
createTrack({ author: 'F7' }), // idx 7
createTrack({ author: 'F8' }), // idx 8
createTrack({ author: 'F9' }), // idx 9
createTrack({ author: 'OutOfWindow' }), // idx 10 — beyond window
]
const queue = createGuildQueue({
currentTrack: createTrack({ author: 'Cur' }),
history: { tracks: { toArray: () => history } },
} as Partial<GuildQueue>)

await replenishQueue(queue)

const ctx = collectRecommendationCandidates.mock.calls[0][0]
const indices: Map<string, number> = ctx.recentArtistIndices
expect(indices.get('cur')).toBe(0)
expect(indices.get('alpha')).toBe(1) // most-recent position wins
expect(indices.get('beta')).toBe(2)
expect(indices.get('f9')).toBe(9)
expect(indices.has('')).toBe(false) // missing author skipped
expect(indices.has('outofwindow')).toBe(false) // window-bounded
})
})

describe('clearSessionMoodCache', () => {
Expand Down
38 changes: 37 additions & 1 deletion packages/bot/src/utils/music/autoplay/replenisher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
hasGenreTag,
type ArtistTagFetcher,
} from './artistTagCache'
import { getGenreFamilies } from './candidateScorer'
import { getGenreFamilies, RECENCY_WINDOW_TRACKS } from './candidateScorer'
import {
buildExcludedUrls,
buildExcludedKeys,
Expand Down Expand Up @@ -286,6 +286,13 @@ async function _replenishQueue(
},
})
const recentArtists = buildRecentArtists(currentTrack, historyTracks)
// Recency-decay needs the real recent queue, not the 3-track seed sample
// (HISTORY_SEED_LIMIT) — otherwise the linear-decay window never fills and
// every recent artist gets a near-max penalty. Feed it the fuller history.
const recentArtistIndices = buildRecentArtistIndices(
currentTrack,
allHistoryTracks,
)
const artistFrequency = buildArtistFrequency(persistentHistory)

// Fetch the token FIRST (it may refresh), then audio features —
Expand Down Expand Up @@ -361,6 +368,7 @@ async function _replenishQueue(
genreContext: candidateGenreContext,
replayFrequentTrackIds: replayFrequency?.trackIds ?? new Set(),
replayFrequentArtists: replayFrequency?.artists ?? new Set(),
recentArtistIndices,
}
const candidates = await collectRecommendationCandidates(
autoplayContext,
Expand Down Expand Up @@ -613,6 +621,34 @@ function buildRecentArtists(
)
}

/**
* Build a map of artist names (lowercased) to their queue position (0 = most recent)
* for recency-decay scoring. Only positions within RECENCY_WINDOW_TRACKS matter —
* the scorer's decay factor clamps to zero past the window — so iteration stops
* there, keeping the map bounded regardless of how much history is fetched upstream.
*/
function buildRecentArtistIndices(
currentTrack: Track,
historyTracks: Track[],
): Map<string, number> {
const indices = new Map<string, number>()
const allTracks = [currentTrack, ...historyTracks]
const seenArtists = new Set<string>()

// Iterate from most recent to oldest, assigning each unique artist its
// most-recent position. Stop at the decay window — later positions score 0.
const limit = Math.min(allTracks.length, RECENCY_WINDOW_TRACKS)
for (let i = 0; i < limit; i++) {
const artist = allTracks[i].author?.toLowerCase()
if (artist && !seenArtists.has(artist)) {
indices.set(artist, i)
seenArtists.add(artist)
}
}

return indices
}

/**
* Look up Last.fm tags for the most recent unique artists in history and
* derive the dominant genre families. Returns a non-empty set only when at
Expand Down
Loading