feat(playlists): daily seed rotation + jitter + 12+13 split for system playlists

Five diversity mechanics — applied to both For-You and Songs-Like-X.

1. For-You seed rotates daily across the user's top-5 most-played
   tracks. pickForYouSeedForDay uses userIDHash(user, day) mod
   len(seeds) so today's mix uses an entirely different similarity
   pool than tomorrow's. Within-day determinism preserved.
2. JitterMagnitude bumped 0.0 → 0.1. The scoring RNG is now seeded
   by userIDHash(user, day) rather than the no-op, so near-tied
   candidates shuffle daily without breaking within-day stability.
3. Head/tail split moves from 20+5 to 12+13. Roughly half the
   playlist comes from the tail now (daily-deterministic via
   tieBreakHash), giving the user substantially different content
   while a 12-track anchor of strong similarity matches keeps the
   mix recognizable.
4. Songs-Like-X seed artists shuffle daily across the user's top-5
   played artists. pickSeedArtistsForDay applies a userIDHash-seeded
   Fisher-Yates and takes 3.
5. scoreAndSortCandidates / pickTopN / pickHeadAndTail gain a userID
   parameter so the RNG can be seeded per-user; existing call sites
   updated; noopRNG removed.

Test fixtures widened similarity gaps (e.g. float64(50-i) instead of
(50-i)/50) so the new jitter (±0.1) doesn't perturb head ordering in
assertions about the head/tail mechanism. New seed_selection_test
coverage for userIDHash + pickForYouSeedForDay + pickSeedArtistsForDay
spans deterministic-within-day, varies-across-days, and graceful
degradation with small candidate pools.

PickTopPlayedTrackForUser replaced by PickTopPlayedTracksForUser
:many in the prior commit (b4801c2). The For-You seed lookup now
goes through pickForYouSeedForDay over the returned slice.
PickSeedArtists's LIMIT widened to 5 in the same prior commit.

For #392 Half A — system playlist content diversity. Half B
(per-user timezone scheduling) is a separate spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 10:19:49 -04:00
parent b4801c2dd3
commit 5cd342d521
3 changed files with 315 additions and 47 deletions
+101 -33
View File
@@ -11,6 +11,7 @@ import (
"errors"
"fmt"
"log/slog"
"math/rand"
"sort"
"time"
@@ -66,6 +67,63 @@ func tieBreakHash(trackID pgtype.UUID, dateStr string) uint64 {
return binary.BigEndian.Uint64(sum[:8])
}
// userIDHash returns a deterministic 64-bit hash of (user_id, dateStr).
// Same family as tieBreakHash, just keyed on user_id instead of
// track_id. Used to drive daily-deterministic seed picks and to seed
// the per-build scoring RNG: same (user, day) → same hash; different
// days → different hashes.
func userIDHash(userID pgtype.UUID, dateStr string) uint64 {
h := sha256.New()
if userID.Valid {
_, _ = h.Write(userID.Bytes[:])
}
_, _ = h.Write([]byte(dateStr))
sum := h.Sum(nil)
return binary.BigEndian.Uint64(sum[:8])
}
// pickForYouSeedForDay picks one of the user's top-played candidate
// tracks as today's For-You seed. With 5 candidates and SHA-256-based
// rotation, each candidate gets picked roughly 1 day in 5; the chosen
// seed drives the similarity candidate pool, so the resulting mix is
// substantially different across days for a user with diverse top-N
// plays.
//
// Degrades gracefully: 1 candidate → returns it; 0 → returns zero UUID
// (caller treats as "no seed available" and skips For-You).
func pickForYouSeedForDay(seeds []pgtype.UUID, userID pgtype.UUID, dateStr string) pgtype.UUID {
if len(seeds) == 0 {
return pgtype.UUID{}
}
idx := int(userIDHash(userID, dateStr) % uint64(len(seeds)))
return seeds[idx]
}
// pickSeedArtistsForDay takes a candidate pool of up to N artists and
// returns up to 3 of them, daily-deterministically shuffled. Each day
// gets a different ordering / selection, but within-day stability is
// preserved (same inputs always produce the same output).
//
// Uses Fisher-Yates over a copy of the input, seeded by userIDHash.
// Degrades gracefully: <3 candidates returns whatever is available in
// shuffled order.
func pickSeedArtistsForDay(pool []pgtype.UUID, userID pgtype.UUID, dateStr string) []pgtype.UUID {
if len(pool) == 0 {
return nil
}
shuffled := make([]pgtype.UUID, len(pool))
copy(shuffled, pool)
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
rng.Shuffle(len(shuffled), func(i, j int) {
shuffled[i], shuffled[j] = shuffled[j], shuffled[i]
})
n := 3
if len(shuffled) < n {
n = len(shuffled)
}
return shuffled[:n]
}
// rankedCandidate is a (track_id, score) pair used during in-memory
// sorting before insert into playlist_tracks. T5 fills these from
// recommendation.Candidate scores.
@@ -77,44 +135,46 @@ type rankedCandidate struct {
const systemMixLength = 25
// systemMixWeights are the fixed scoring weights used by the cron worker.
// JitterMagnitude is 0 because daily determinism comes from tieBreakHash;
// any randomness would defeat the within-day stability invariant.
// JitterMagnitude is small (0.1) and combined with a userIDHash-seeded
// RNG (see scoreAndSortCandidates) — same (user, day) produces same
// scores within a day, but near-tied candidates reshuffle across days
// so the playlist doesn't feel frozen.
var systemMixWeights = recommendation.ScoringWeights{
BaseWeight: 1.0,
LikeBoost: 2.0,
RecencyWeight: 1.0,
SkipPenalty: 2.0,
JitterMagnitude: 0.0,
JitterMagnitude: 0.1,
ContextWeight: 0.5,
SimilarityWeight: 1.5,
}
// noopRNG returns 0 for any call. Combined with JitterMagnitude=0 it makes
// 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).
// playlist; forYouTailN is the number sampled from positions 2*headN
// onward, daily-deterministic via tieBreakHash. The 12 + 13 split
// (~50% from the tail) gives the user a substantially different mix
// day-to-day while preserving a recognizable anchor of strong
// similarity matches. Songs-like-X keeps simple pickTopN (the
// seed-artist context already frames the "you'll like this" promise).
const (
forYouHeadN = 20
forYouTailN = 5
forYouHeadN = 12
forYouTailN = 13
)
// 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 {
// tieBreakHash). The scoring RNG is seeded by userIDHash so jitter is
// deterministic per (user, day) but rotates across days. Pure — no
// truncation, no cap.
func scoreAndSortCandidates(cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time) []recommendation.Candidate {
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
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)}
pairs[i] = scored{c: c, score: recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64)}
}
sort.SliceStable(pairs, func(i, j int) bool {
if pairs[i].score != pairs[j].score {
@@ -205,14 +265,17 @@ func BuildSystemPlaylists(ctx context.Context, pool *pgxpool.Pool, logger *slog.
}
}()
// 1. For-You: synth seed from top recently-played track, pull candidates.
forYouSeed, err := q.PickTopPlayedTrackForUser(ctx, userID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
buildErr = fmt.Errorf("pick for-you seed: %w", err)
// 1. For-You: pick today's seed from the user's top-5 played tracks.
// Seed rotates daily via userIDHash so the candidate pool shifts
// across days while staying stable within a day.
forYouSeeds, err := q.PickTopPlayedTracksForUser(ctx, userID)
if err != nil {
buildErr = fmt.Errorf("pick for-you seed candidates: %w", err)
return buildErr
}
forYouSeed := pickForYouSeedForDay(forYouSeeds, userID, dateStr)
var forYouTracks []rankedCandidate
if err == nil && forYouSeed.Valid {
if forYouSeed.Valid {
zeroVec := recommendation.SessionVector{Seed: true}
cands, cerr := recommendation.LoadCandidatesFromSimilarity(
ctx, q, userID, forYouSeed,
@@ -227,14 +290,16 @@ func BuildSystemPlaylists(ctx context.Context, pool *pgxpool.Pool, logger *slog.
// 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)
forYouTracks = pickHeadAndTail(cands, userID, dateStr, now, forYouHeadN, forYouTailN)
} else {
logger.Warn("system playlist: for-you candidates load failed; skipping",
"user_id", uuidStringPL(userID), "err", cerr)
}
}
// 2. Seed artists for "Songs like {X}".
// 2. Seed artists for "Songs like {X}". SQL returns up to 5 candidates
// in score order; pickSeedArtistsForDay shuffles them daily-
// deterministically and takes 3 so the set of mixes rotates day-to-day.
seedRows, err := q.PickSeedArtists(ctx, userID)
if err != nil {
buildErr = fmt.Errorf("pick seed artists: %w", err)
@@ -246,7 +311,8 @@ func BuildSystemPlaylists(ctx context.Context, pool *pgxpool.Pool, logger *slog.
ArtistID: r.ArtistID, Score: r.Score,
})
}
seeds := pickSeedArtistsFromRows(seedRowsLocal)
seedPool := pickSeedArtistsFromRows(seedRowsLocal)
seeds := pickSeedArtistsForDay(seedPool, userID, dateStr)
type seedMix struct {
ArtistID pgtype.UUID
@@ -285,7 +351,7 @@ func BuildSystemPlaylists(ctx context.Context, pool *pgxpool.Pool, logger *slog.
filtered = append(filtered, c)
}
}
tracks := pickTopN(filtered, dateStr, now, systemMixLength)
tracks := pickTopN(filtered, userID, dateStr, now, systemMixLength)
seedMixes = append(seedMixes, seedMix{
ArtistID: artistID,
ArtistName: artistRow.Name,
@@ -385,17 +451,18 @@ func BuildSystemPlaylists(ctx context.Context, pool *pgxpool.Pool, logger *slog.
// 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 {
sorted := scoreAndSortCandidates(cands, dateStr, now)
func pickTopN(cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time, n int) []rankedCandidate {
sorted := scoreAndSortCandidates(cands, userID, dateStr, now)
capped := capCandidatesByAlbumAndArtist(sorted)
if len(capped) > n {
capped = capped[:n]
}
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
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),
Score: recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64),
}
}
return out
@@ -414,9 +481,10 @@ func pickTopN(cands []recommendation.Candidate, dateStr string, now time.Time, n
// 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)
func pickHeadAndTail(cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time, headN, tailN int) []rankedCandidate {
sorted := scoreAndSortCandidates(cands, userID, dateStr, now)
capped := capCandidatesByAlbumAndArtist(sorted)
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
total := headN + tailN
if len(capped) <= total {
@@ -428,7 +496,7 @@ func pickHeadAndTail(cands []recommendation.Candidate, dateStr string, now time.
for i := 0; i < total; i++ {
out[i] = rankedCandidate{
TrackID: capped[i].Track.ID,
Score: recommendation.Score(capped[i].Inputs, systemMixWeights, now, noopRNG),
Score: recommendation.Score(capped[i].Inputs, systemMixWeights, now, rng.Float64),
}
}
return out
@@ -464,7 +532,7 @@ func pickHeadAndTail(cands []recommendation.Candidate, dateStr string, now time.
for i, c := range combined {
out[i] = rankedCandidate{
TrackID: c.Track.ID,
Score: recommendation.Score(c.Inputs, systemMixWeights, now, noopRNG),
Score: recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64),
}
}
return out