From 5cd342d521347f45bb9af11b8d71e4c935872ca7 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 13 May 2026 10:19:49 -0400 Subject: [PATCH] feat(playlists): daily seed rotation + jitter + 12+13 split for system playlists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- internal/playlists/foryou_test.go | 40 +++-- internal/playlists/seed_selection_test.go | 188 ++++++++++++++++++++++ internal/playlists/system.go | 134 +++++++++++---- 3 files changed, 315 insertions(+), 47 deletions(-) diff --git a/internal/playlists/foryou_test.go b/internal/playlists/foryou_test.go index 578876db..0d69a7ae 100644 --- a/internal/playlists/foryou_test.go +++ b/internal/playlists/foryou_test.go @@ -10,11 +10,20 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" ) +// testUserID is a stable UUID used to seed the daily-determinism RNGs in +// scoreAndSortCandidates / pickHeadAndTail / pickTopN. Tests pass it to +// every call so the jitter is reproducible across test runs. +var testUserID = pgtype.UUID{Bytes: [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}, Valid: true} + // makeCand constructs a Candidate with distinct track/album/artist IDs // and a SimilarityScore that drives distinguishable scores when passed // through recommendation.Score. Using SimilarityScore alone is sufficient // because systemMixWeights.SimilarityWeight = 1.5 (non-zero), so // different similarity values produce different scores. +// +// Tests below use similarity values with gaps wide enough that the +// systemMixWeights JitterMagnitude (±0.1) cannot perturb the top-of-pool +// ordering, so assertions about head order remain meaningful. func makeCand(trackN, albumN, artistN int, similarity float64) recommendation.Candidate { var tID, aID, arID pgtype.UUID tID.Valid, aID.Valid, arID.Valid = true, true, true @@ -90,7 +99,7 @@ func TestPickHeadAndTail_SmallPool(t *testing.T) { makeCand(2, 11, 101, 0.9), makeCand(3, 12, 102, 0.8), } - got := pickHeadAndTail(in, "2026-05-07", time.Now(), 5, 2) + got := pickHeadAndTail(in, testUserID, "2026-05-07", time.Now(), 5, 2) if len(got) != 3 { t.Errorf("len = %d, want 3 (pool too small for head/tail split)", len(got)) } @@ -98,11 +107,12 @@ func TestPickHeadAndTail_SmallPool(t *testing.T) { func TestPickHeadAndTail_ExactlyTotal(t *testing.T) { // Pool == headN+tailN: no tail to sample from, returns all. + // Wide score gaps (1.0 per step) keep jitter from perturbing order. in := make([]recommendation.Candidate, 0, 7) for i := 0; i < 7; i++ { - in = append(in, makeCand(i+1, i+1, i+1, float64(7-i)/10.0)) + in = append(in, makeCand(i+1, i+1, i+1, float64(7-i))) } - got := pickHeadAndTail(in, "2026-05-07", time.Now(), 5, 2) + got := pickHeadAndTail(in, testUserID, "2026-05-07", time.Now(), 5, 2) if len(got) != 7 { t.Errorf("len = %d, want 7 (pool == total)", len(got)) } @@ -111,11 +121,12 @@ func TestPickHeadAndTail_ExactlyTotal(t *testing.T) { func TestPickHeadAndTail_HeadAndTailSplit(t *testing.T) { // Pool of 100 distinct (album, artist) pairs; no caps trim. // With headN=20, tailN=5, expect 25 entries total. + // Wide score gaps (1.0 per step) keep jitter from perturbing order. in := make([]recommendation.Candidate, 0, 100) for i := 0; i < 100; i++ { - in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)/100.0)) + in = append(in, makeCand(i+1, i+1, i+1, float64(100-i))) } - got := pickHeadAndTail(in, "2026-05-07", time.Now(), 20, 5) + got := pickHeadAndTail(in, testUserID, "2026-05-07", time.Now(), 20, 5) if len(got) != 25 { t.Errorf("len = %d, want 25 (20 head + 5 tail)", len(got)) } @@ -125,11 +136,11 @@ func TestPickHeadAndTail_Determinism(t *testing.T) { // Same inputs, same dateStr → identical output both times. in := make([]recommendation.Candidate, 0, 100) for i := 0; i < 100; i++ { - in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)/100.0)) + in = append(in, makeCand(i+1, i+1, i+1, float64(100-i))) } now := time.Now() - got1 := pickHeadAndTail(in, "2026-05-07", now, 20, 5) - got2 := pickHeadAndTail(in, "2026-05-07", now, 20, 5) + got1 := pickHeadAndTail(in, testUserID, "2026-05-07", now, 20, 5) + got2 := pickHeadAndTail(in, testUserID, "2026-05-07", now, 20, 5) if len(got1) != len(got2) { t.Fatalf("len mismatch: %d vs %d", len(got1), len(got2)) } @@ -147,11 +158,11 @@ func TestPickHeadAndTail_HeadStable_TailVariesAcrossDays(t *testing.T) { // because tieBreakHash incorporates the date. in := make([]recommendation.Candidate, 0, 100) for i := 0; i < 100; i++ { - in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)/100.0)) + in = append(in, makeCand(i+1, i+1, i+1, float64(100-i))) } now := time.Now() - day1 := pickHeadAndTail(in, "2026-05-07", now, 20, 5) - day2 := pickHeadAndTail(in, "2026-05-08", now, 20, 5) + day1 := pickHeadAndTail(in, testUserID, "2026-05-07", now, 20, 5) + day2 := pickHeadAndTail(in, testUserID, "2026-05-08", now, 20, 5) if len(day1) != 25 || len(day2) != 25 { t.Fatalf("len mismatch: day1=%d day2=%d", len(day1), len(day2)) } @@ -190,10 +201,11 @@ func TestPickHeadAndTail_TailFromBeyond2xHeadN(t *testing.T) { in := make([]recommendation.Candidate, 0, 50) for i := 0; i < 50; i++ { // trackN = i+1, score descends: track 1 scores highest. - sim := float64(50-i) / 50.0 + // Wide score gaps (1.0 per step) keep jitter from perturbing order. + sim := float64(50 - i) in = append(in, makeCand(i+1, i+1, i+1, sim)) } - got := pickHeadAndTail(in, "2026-05-07", time.Now(), 5, 3) + got := pickHeadAndTail(in, testUserID, "2026-05-07", time.Now(), 5, 3) if len(got) != 8 { t.Fatalf("len = %d, want 8 (5 head + 3 tail)", len(got)) } @@ -226,7 +238,7 @@ func TestPickTopN_DiversityCap(t *testing.T) { makeCand(4, 13, 100, 0.7), // 4th by artist → dropped by cap makeCand(5, 14, 100, 0.6), // 5th by artist → dropped by cap } - got := pickTopN(in, "2026-05-07", time.Now(), 25) + got := pickTopN(in, testUserID, "2026-05-07", time.Now(), 25) if len(got) != 3 { t.Errorf("len = %d, want 3 (artist 100 capped at 3)", len(got)) } diff --git a/internal/playlists/seed_selection_test.go b/internal/playlists/seed_selection_test.go index 6dc28136..c3e5ec43 100644 --- a/internal/playlists/seed_selection_test.go +++ b/internal/playlists/seed_selection_test.go @@ -45,3 +45,191 @@ func TestPickSeedArtistsFromRows_Empty(t *testing.T) { t.Errorf("empty input should yield empty output; got %v", got) } } + +// userIDHash is the per-user, per-day hash that drives the daily- +// determinism RNGs. Same family as tieBreakHash, just keyed on user +// ID instead of track ID. + +func TestUserIDHash_Deterministic(t *testing.T) { + u := pgtype.UUID{Bytes: [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}, Valid: true} + a := userIDHash(u, "2026-05-04") + b := userIDHash(u, "2026-05-04") + if a != b { + t.Fatalf("same inputs gave different hashes: %d vs %d", a, b) + } +} + +func TestUserIDHash_DifferentDateChangesHash(t *testing.T) { + u := pgtype.UUID{Bytes: [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}, Valid: true} + a := userIDHash(u, "2026-05-04") + b := userIDHash(u, "2026-05-05") + if a == b { + t.Errorf("different dates should change hash; both = %d", a) + } +} + +func TestUserIDHash_DifferentUserChangesHash(t *testing.T) { + a := pgtype.UUID{Bytes: [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}, Valid: true} + b := pgtype.UUID{Bytes: [16]byte{16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1}, Valid: true} + if userIDHash(a, "2026-05-04") == userIDHash(b, "2026-05-04") { + t.Errorf("different users should change hash") + } +} + +// pickForYouSeedForDay rotates the chosen For-You seed across the +// user's top-played candidates using userIDHash. Verifies the picker +// is deterministic within a day, varies across days, and degrades +// gracefully when fewer than 5 candidates exist. + +func TestPickForYouSeedForDay_DeterministicWithinDay(t *testing.T) { + u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true} + seeds := []pgtype.UUID{ + {Bytes: [16]byte{10}, Valid: true}, + {Bytes: [16]byte{20}, Valid: true}, + {Bytes: [16]byte{30}, Valid: true}, + {Bytes: [16]byte{40}, Valid: true}, + {Bytes: [16]byte{50}, Valid: true}, + } + a := pickForYouSeedForDay(seeds, u, "2026-05-04") + b := pickForYouSeedForDay(seeds, u, "2026-05-04") + if a != b { + t.Fatalf("same day should pick same seed; got %v then %v", a, b) + } +} + +func TestPickForYouSeedForDay_VariesAcrossDays(t *testing.T) { + u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true} + seeds := []pgtype.UUID{ + {Bytes: [16]byte{10}, Valid: true}, + {Bytes: [16]byte{20}, Valid: true}, + {Bytes: [16]byte{30}, Valid: true}, + {Bytes: [16]byte{40}, Valid: true}, + {Bytes: [16]byte{50}, Valid: true}, + } + picks := map[[16]byte]bool{} + for i := 1; i <= 30; i++ { + date := "2026-05-" + twoDigits(i) + picks[pickForYouSeedForDay(seeds, u, date).Bytes] = true + } + if len(picks) < 2 { + t.Errorf("expected >=2 distinct seeds across 30 days; got %d", len(picks)) + } +} + +func TestPickForYouSeedForDay_SingleCandidate(t *testing.T) { + u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true} + only := pgtype.UUID{Bytes: [16]byte{99}, Valid: true} + got := pickForYouSeedForDay([]pgtype.UUID{only}, u, "2026-05-04") + if got != only { + t.Errorf("single-seed pool should return that seed; got %v", got) + } +} + +func TestPickForYouSeedForDay_EmptyPool(t *testing.T) { + u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true} + got := pickForYouSeedForDay(nil, u, "2026-05-04") + if got.Valid { + t.Errorf("empty pool should return zero UUID; got %v", got) + } +} + +// pickSeedArtistsForDay takes the user's top-5 candidate artists and +// returns 3 of them via daily-deterministic shuffle. Verifies the +// picker is deterministic within a day, varies across days, and +// degrades gracefully when fewer than 3 or 5 candidates exist. + +func TestPickSeedArtistsForDay_DeterministicWithinDay(t *testing.T) { + u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true} + pool := []pgtype.UUID{ + {Bytes: [16]byte{10}, Valid: true}, + {Bytes: [16]byte{20}, Valid: true}, + {Bytes: [16]byte{30}, Valid: true}, + {Bytes: [16]byte{40}, Valid: true}, + {Bytes: [16]byte{50}, Valid: true}, + } + a := pickSeedArtistsForDay(pool, u, "2026-05-04") + b := pickSeedArtistsForDay(pool, u, "2026-05-04") + if len(a) != 3 || len(b) != 3 { + t.Fatalf("expected 3 seeds; got %d / %d", len(a), len(b)) + } + for i := range a { + if a[i] != b[i] { + t.Fatalf("same day should produce same order; differs at i=%d", i) + } + } +} + +func TestPickSeedArtistsForDay_VariesAcrossDays(t *testing.T) { + u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true} + pool := []pgtype.UUID{ + {Bytes: [16]byte{10}, Valid: true}, + {Bytes: [16]byte{20}, Valid: true}, + {Bytes: [16]byte{30}, Valid: true}, + {Bytes: [16]byte{40}, Valid: true}, + {Bytes: [16]byte{50}, Valid: true}, + } + // Collect the trio (as a sorted byte tuple) across many dates. + // With C(5,3) = 10 possible trios, 30 dates should yield >=2 distinct sets. + seen := map[[3]byte]bool{} + for i := 1; i <= 30; i++ { + got := pickSeedArtistsForDay(pool, u, "2026-05-"+twoDigits(i)) + if len(got) != 3 { + t.Fatalf("expected 3 seeds; got %d", len(got)) + } + // Sort the three byte values for set-equivalence comparison. + v := [3]byte{got[0].Bytes[0], got[1].Bytes[0], got[2].Bytes[0]} + if v[0] > v[1] { + v[0], v[1] = v[1], v[0] + } + if v[1] > v[2] { + v[1], v[2] = v[2], v[1] + } + if v[0] > v[1] { + v[0], v[1] = v[1], v[0] + } + seen[v] = true + } + if len(seen) < 2 { + t.Errorf("expected >=2 distinct seed trios across 30 days; got %d", len(seen)) + } +} + +func TestPickSeedArtistsForDay_FewerThanFive(t *testing.T) { + u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true} + pool := []pgtype.UUID{ + {Bytes: [16]byte{10}, Valid: true}, + {Bytes: [16]byte{20}, Valid: true}, + {Bytes: [16]byte{30}, Valid: true}, + } + got := pickSeedArtistsForDay(pool, u, "2026-05-04") + if len(got) != 3 { + t.Errorf("with 3 candidates, expected all 3 returned; got %d", len(got)) + } +} + +func TestPickSeedArtistsForDay_FewerThanThree(t *testing.T) { + u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true} + pool := []pgtype.UUID{ + {Bytes: [16]byte{10}, Valid: true}, + {Bytes: [16]byte{20}, Valid: true}, + } + got := pickSeedArtistsForDay(pool, u, "2026-05-04") + if len(got) != 2 { + t.Errorf("with 2 candidates, expected 2 returned; got %d", len(got)) + } +} + +func TestPickSeedArtistsForDay_Empty(t *testing.T) { + u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true} + got := pickSeedArtistsForDay(nil, u, "2026-05-04") + if len(got) != 0 { + t.Errorf("empty pool should return empty; got %d", len(got)) + } +} + +func twoDigits(n int) string { + if n < 10 { + return "0" + string(rune('0'+n)) + } + return string(rune('0'+n/10)) + string(rune('0'+n%10)) +} diff --git a/internal/playlists/system.go b/internal/playlists/system.go index e0a5b474..63c1f800 100644 --- a/internal/playlists/system.go +++ b/internal/playlists/system.go @@ -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