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:
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
+101
-33
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user