feat(server/playlists): For-You head+tail + diversity caps
Two improvements to the system playlist builder: 1. Per-artist (<=3) and per-album (<=2) caps applied to the pickTopN truncation step, using the same numeric caps Discover already enforces. Both For-You and Songs-like-X benefit. Same skewed candidate pool no longer collapses to "10 tracks from the same artist" — the playlist always carries at least 9 distinct artists in 25 slots. 2. New pickHeadAndTail function for For-You: 20 top-similarity tracks + 5 sampled from the tail (positions 2*headN onward of the score-sorted, cap-applied pool). Tail sampling uses tieBreakHash for daily determinism — same user same day still sees the same playlist, but the daily refresh feels less stuck-in-a-rut. Tail tracks are still similarity-related (they passed the similarity candidate filter) so the user should enjoy them, just from artists they wouldn't have surfaced via strict top-N ranking. Songs-like-X keeps the simple pickTopN call — the seed-artist context already provides the "you'll like this" framing without needing a tail injection. Refactors pickTopN internals: now sorts candidates first via scoreAndSortCandidates, applies the cap on []Candidate via capCandidatesByAlbumAndArtist, and truncates. Removes the now- dead stableSortByScoreThenHash helper (only used in old pickTopN). The cap helper mirrors capByAlbumAndArtist in discover.go but operates on recommendation.Candidate so it sees Track.AlbumID / Track.ArtistID directly. Tests cover the cap helper truth table, head+tail split with small/large pools, buffer-zone exclusion, daily determinism, and cross-day tail variance. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+154
-22
@@ -65,17 +65,6 @@ type rankedCandidate struct {
|
||||
Score float64
|
||||
}
|
||||
|
||||
// stableSortByScoreThenHash sorts candidates in-place by score DESC,
|
||||
// breaking ties with tieBreakHash(track_id, dateStr).
|
||||
func stableSortByScoreThenHash(cands []rankedCandidate, dateStr string) {
|
||||
sort.SliceStable(cands, func(i, j int) bool {
|
||||
if cands[i].Score != cands[j].Score {
|
||||
return cands[i].Score > cands[j].Score
|
||||
}
|
||||
return tieBreakHash(cands[i].TrackID, dateStr) < tieBreakHash(cands[j].TrackID, dateStr)
|
||||
})
|
||||
}
|
||||
|
||||
const systemMixLength = 25
|
||||
|
||||
// systemMixWeights are the fixed scoring weights used by the cron worker.
|
||||
@@ -95,6 +84,70 @@ var systemMixWeights = recommendation.ScoringWeights{
|
||||
// recommendation.Score fully deterministic.
|
||||
func noopRNG() float64 { return 0 }
|
||||
|
||||
// forYouHeadN is the number of top-scored tracks that anchor the For-You
|
||||
// playlist. forYouTailN is the number of diversity picks sampled from the
|
||||
// tail of the score-sorted pool (positions 2*forYouHeadN onward), injected
|
||||
// after the head to give users a daily-deterministic surprise without
|
||||
// compromising quality. Songs-like-X keeps simple pickTopN (the seed-artist
|
||||
// context already frames the "you'll like this" promise).
|
||||
const (
|
||||
forYouHeadN = 20
|
||||
forYouTailN = 5
|
||||
)
|
||||
|
||||
// scoreAndSortCandidates scores every candidate with recommendation.Score
|
||||
// and returns a new slice sorted by score DESC (ties broken by
|
||||
// tieBreakHash). Pure — no truncation, no cap.
|
||||
func scoreAndSortCandidates(cands []recommendation.Candidate, dateStr string, now time.Time) []recommendation.Candidate {
|
||||
type scored struct {
|
||||
c recommendation.Candidate
|
||||
score float64
|
||||
}
|
||||
pairs := make([]scored, len(cands))
|
||||
for i, c := range cands {
|
||||
pairs[i] = scored{c: c, score: recommendation.Score(c.Inputs, systemMixWeights, now, noopRNG)}
|
||||
}
|
||||
sort.SliceStable(pairs, func(i, j int) bool {
|
||||
if pairs[i].score != pairs[j].score {
|
||||
return pairs[i].score > pairs[j].score
|
||||
}
|
||||
return tieBreakHash(pairs[i].c.Track.ID, dateStr) < tieBreakHash(pairs[j].c.Track.ID, dateStr)
|
||||
})
|
||||
out := make([]recommendation.Candidate, len(pairs))
|
||||
for i, p := range pairs {
|
||||
out[i] = p.c
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// capCandidatesByAlbumAndArtist trims a candidate list so no single
|
||||
// album appears more than discoverMaxTracksPerAlbum times and no
|
||||
// single artist appears more than discoverMaxTracksPerArtist times.
|
||||
// Mirrors capByAlbumAndArtist (which operates on []discoverTrack);
|
||||
// here the input/output is []recommendation.Candidate so For-You and
|
||||
// Songs-like-X can apply the same diversity caps as Discover.
|
||||
//
|
||||
// Preserves input order. Reuses the discoverMax* constants —
|
||||
// playlists across all three system variants get consistent
|
||||
// diversity behavior.
|
||||
func capCandidatesByAlbumAndArtist(cands []recommendation.Candidate) []recommendation.Candidate {
|
||||
albumCount := map[pgtype.UUID]int{}
|
||||
artistCount := map[pgtype.UUID]int{}
|
||||
out := make([]recommendation.Candidate, 0, len(cands))
|
||||
for _, c := range cands {
|
||||
if albumCount[c.Track.AlbumID] >= discoverMaxTracksPerAlbum {
|
||||
continue
|
||||
}
|
||||
if artistCount[c.Track.ArtistID] >= discoverMaxTracksPerArtist {
|
||||
continue
|
||||
}
|
||||
albumCount[c.Track.AlbumID]++
|
||||
artistCount[c.Track.ArtistID]++
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// BuildSystemPlaylists builds the user's daily system mixes (one For-You +
|
||||
// up to 3 Songs-like-{seed} mixes). Atomic-replace inside one tx;
|
||||
// concurrency-guarded via system_playlist_runs.in_flight; deterministic
|
||||
@@ -160,7 +213,12 @@ func BuildSystemPlaylists(ctx context.Context, pool *pgxpool.Pool, logger *slog.
|
||||
recommendation.DefaultCandidateSourceLimits(),
|
||||
)
|
||||
if cerr == nil {
|
||||
forYouTracks = pickTopN(cands, dateStr, now, systemMixLength)
|
||||
// For-You uses head+tail composition: forYouHeadN top-similarity
|
||||
// tracks + forYouTailN tail-sampled to inject daily-deterministic
|
||||
// surprise. Songs-like-X keeps pickTopN (top-25 with caps) since
|
||||
// the seed-artist context already provides the "you'll like this"
|
||||
// framing.
|
||||
forYouTracks = pickHeadAndTail(cands, dateStr, now, forYouHeadN, forYouTailN)
|
||||
} else {
|
||||
logger.Warn("system playlist: for-you candidates load failed; skipping",
|
||||
"user_id", uuidStringPL(userID), "err", cerr)
|
||||
@@ -311,19 +369,93 @@ func BuildSystemPlaylists(ctx context.Context, pool *pgxpool.Pool, logger *slog.
|
||||
return nil
|
||||
}
|
||||
|
||||
// pickTopN ranks candidates with recommendation.Score, sorts by score-DESC
|
||||
// breaking ties via tieBreakHash(track_id, dateStr), then truncates to N.
|
||||
// pickTopN sorts candidates by score DESC, applies per-album (<=2) /
|
||||
// per-artist (<=3) diversity caps matching Discover's behavior, then
|
||||
// truncates to n. Used by Songs-like-X (and as the fallback inside
|
||||
// pickHeadAndTail for small pools).
|
||||
func pickTopN(cands []recommendation.Candidate, dateStr string, now time.Time, n int) []rankedCandidate {
|
||||
ranked := make([]rankedCandidate, 0, len(cands))
|
||||
for _, c := range cands {
|
||||
s := recommendation.Score(c.Inputs, systemMixWeights, now, noopRNG)
|
||||
ranked = append(ranked, rankedCandidate{TrackID: c.Track.ID, Score: s})
|
||||
sorted := scoreAndSortCandidates(cands, dateStr, now)
|
||||
capped := capCandidatesByAlbumAndArtist(sorted)
|
||||
if len(capped) > n {
|
||||
capped = capped[:n]
|
||||
}
|
||||
stableSortByScoreThenHash(ranked, dateStr)
|
||||
if len(ranked) > n {
|
||||
ranked = ranked[:n]
|
||||
out := make([]rankedCandidate, len(capped))
|
||||
for i, c := range capped {
|
||||
out[i] = rankedCandidate{
|
||||
TrackID: c.Track.ID,
|
||||
Score: recommendation.Score(c.Inputs, systemMixWeights, now, noopRNG),
|
||||
}
|
||||
}
|
||||
return ranked
|
||||
return out
|
||||
}
|
||||
|
||||
// pickHeadAndTail picks headN from the score-sorted head plus tailN from
|
||||
// positions 2*headN onward (the tail), with the tail sampled
|
||||
// daily-deterministically via tieBreakHash. Caps applied before the
|
||||
// head/tail split. Used by For-You only.
|
||||
//
|
||||
// The "tail" — candidates ranked beyond 2*headN — is still similarity-
|
||||
// related (every candidate passed the similarity filter) but isn't among
|
||||
// the obvious top hits. Sampling from there gives the user variety they'll
|
||||
// probably enjoy without resorting to genuinely random unrelated tracks.
|
||||
//
|
||||
// Falls back to standard pickTopN behavior when the candidate pool is too
|
||||
// small to support a meaningful head/tail split (capped pool <=
|
||||
// headN+tailN, or no candidates at or beyond position 2*headN).
|
||||
func pickHeadAndTail(cands []recommendation.Candidate, dateStr string, now time.Time, headN, tailN int) []rankedCandidate {
|
||||
sorted := scoreAndSortCandidates(cands, dateStr, now)
|
||||
capped := capCandidatesByAlbumAndArtist(sorted)
|
||||
|
||||
total := headN + tailN
|
||||
if len(capped) <= total {
|
||||
// Pool too small for a head/tail split — return up to total entries.
|
||||
if len(capped) < total {
|
||||
total = len(capped)
|
||||
}
|
||||
out := make([]rankedCandidate, total)
|
||||
for i := 0; i < total; i++ {
|
||||
out[i] = rankedCandidate{
|
||||
TrackID: capped[i].Track.ID,
|
||||
Score: recommendation.Score(capped[i].Inputs, systemMixWeights, now, noopRNG),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
head := capped[:headN]
|
||||
|
||||
tailStart := 2 * headN
|
||||
if tailStart >= len(capped) {
|
||||
tailStart = headN
|
||||
}
|
||||
|
||||
// Defensive copy so that sorting the tail pool does not mutate capped.
|
||||
tailPool := append([]recommendation.Candidate{}, capped[tailStart:]...)
|
||||
|
||||
// Sort tail pool by tieBreakHash (daily-deterministic), take tailN.
|
||||
// Sample is stable across requests within a day but varies across days.
|
||||
sort.SliceStable(tailPool, func(i, j int) bool {
|
||||
return tieBreakHash(tailPool[i].Track.ID, dateStr) < tieBreakHash(tailPool[j].Track.ID, dateStr)
|
||||
})
|
||||
tail := tailPool
|
||||
if len(tail) > tailN {
|
||||
tail = tail[:tailN]
|
||||
}
|
||||
|
||||
// Combine: head order preserved (score-sorted), tail in tieBreakHash
|
||||
// order. "First similar, then surprise" reads naturally in playback.
|
||||
combined := make([]recommendation.Candidate, 0, len(head)+len(tail))
|
||||
combined = append(combined, head...)
|
||||
combined = append(combined, tail...)
|
||||
|
||||
out := make([]rankedCandidate, len(combined))
|
||||
for i, c := range combined {
|
||||
out[i] = rankedCandidate{
|
||||
TrackID: c.Track.ID,
|
||||
Score: recommendation.Score(c.Inputs, systemMixWeights, now, noopRNG),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// insertSystemPlaylist inserts a kind='system' playlists row plus its
|
||||
|
||||
Reference in New Issue
Block a user