feat(playlists): For You composition v2 — multi-seed blend + weighted fresh tail
Two approved composition changes (#1269), mechanism only — the taste/fresh share stays data-decided (#1252) and pick_kind attribution is unchanged. Multi-seed blending: each day's build now seeds from up to 3 of the user's top-5 tracks (pickDailySeeds, the generalized daily shuffle) instead of one rotating anchor, so the mix spans neighborhoods within a day and stops feeling bipolar as the rotation swings between dissimilar seeds. Per-seed pools merge first-seen-deduped; the head is filled best-first under 50/30/20 per-seed quotas (60/40 for two seeds) so one neighborhood can't monopolize it, with thin-seed quota spilling best-first. Score-weighted fresh tail: the tail sample (rank 2*headN onward) was uniform — the 380th-best candidate as likely as the 101st. It now uses deterministic Efraimidis-Spirakis keys with weight halving every 50 ranks, so freshness keeps its "you'll probably enjoy this" half while still rotating daily. The retired single-seed picker's one other caller, You-might-like, moves to pickDailySeeds(n=1) — a single neighborhood per day is right for a short shelf, and the behavior note is inline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
This commit is contained in:
@@ -513,10 +513,10 @@ SELECT id
|
||||
// tier 2 (only if tiers 0+1 empty) liked tracks
|
||||
//
|
||||
// Returns up to 5 ids; tie-break by track_id for determinism. The
|
||||
// Go-side picker (pickForYouSeedForDay) rotates one per day via
|
||||
// userIDHash. Widened from a hard 7-day window, which made For-You
|
||||
// disappear after a week of not listening and never recover on a
|
||||
// self-hosted library with sparse history.
|
||||
// Go-side picker (pickDailySeeds) draws the day's seeds from these.
|
||||
// Widened from a hard 7-day window, which made For-You disappear
|
||||
// after a week of not listening and never recover on a self-hosted
|
||||
// library with sparse history.
|
||||
func (q *Queries) PickTopPlayedTracksForUser(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) {
|
||||
rows, err := q.db.Query(ctx, pickTopPlayedTracksForUser, userID)
|
||||
if err != nil {
|
||||
|
||||
@@ -131,10 +131,10 @@ SELECT c.artist_id,
|
||||
-- tier 1 (only if tier 0 empty) all-time top non-skip plays
|
||||
-- tier 2 (only if tiers 0+1 empty) liked tracks
|
||||
-- Returns up to 5 ids; tie-break by track_id for determinism. The
|
||||
-- Go-side picker (pickForYouSeedForDay) rotates one per day via
|
||||
-- userIDHash. Widened from a hard 7-day window, which made For-You
|
||||
-- disappear after a week of not listening and never recover on a
|
||||
-- self-hosted library with sparse history.
|
||||
-- Go-side picker (pickDailySeeds) draws the day's seeds from these.
|
||||
-- Widened from a hard 7-day window, which made For-You disappear
|
||||
-- after a week of not listening and never recover on a self-hosted
|
||||
-- library with sparse history.
|
||||
WITH recent AS (
|
||||
SELECT t.id, COUNT(*) AS c, 0 AS tier
|
||||
FROM play_events pe
|
||||
|
||||
@@ -99,7 +99,7 @@ func TestPickHeadAndTail_SmallPool(t *testing.T) {
|
||||
makeCand(2, 11, 101, 0.9),
|
||||
makeCand(3, 12, 102, 0.8),
|
||||
}
|
||||
got := pickHeadAndTail(in, testUserID, "2026-05-07", time.Now(), 5, 2)
|
||||
got := pickHeadAndTail(in, nil, 1, 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))
|
||||
}
|
||||
@@ -112,7 +112,7 @@ func TestPickHeadAndTail_ExactlyTotal(t *testing.T) {
|
||||
for i := 0; i < 7; i++ {
|
||||
in = append(in, makeCand(i+1, i+1, i+1, float64(7-i)))
|
||||
}
|
||||
got := pickHeadAndTail(in, testUserID, "2026-05-07", time.Now(), 5, 2)
|
||||
got := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", time.Now(), 5, 2)
|
||||
if len(got) != 7 {
|
||||
t.Errorf("len = %d, want 7 (pool == total)", len(got))
|
||||
}
|
||||
@@ -126,7 +126,7 @@ func TestPickHeadAndTail_HeadAndTailSplit(t *testing.T) {
|
||||
for i := 0; i < 100; i++ {
|
||||
in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)))
|
||||
}
|
||||
got := pickHeadAndTail(in, testUserID, "2026-05-07", time.Now(), 20, 5)
|
||||
got := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", time.Now(), 20, 5)
|
||||
if len(got) != 25 {
|
||||
t.Errorf("len = %d, want 25 (20 head + 5 tail)", len(got))
|
||||
}
|
||||
@@ -139,8 +139,8 @@ func TestPickHeadAndTail_Determinism(t *testing.T) {
|
||||
in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)))
|
||||
}
|
||||
now := time.Now()
|
||||
got1 := pickHeadAndTail(in, testUserID, "2026-05-07", now, 20, 5)
|
||||
got2 := pickHeadAndTail(in, testUserID, "2026-05-07", now, 20, 5)
|
||||
got1 := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", now, 20, 5)
|
||||
got2 := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", now, 20, 5)
|
||||
if len(got1) != len(got2) {
|
||||
t.Fatalf("len mismatch: %d vs %d", len(got1), len(got2))
|
||||
}
|
||||
@@ -161,8 +161,8 @@ func TestPickHeadAndTail_HeadStable_TailVariesAcrossDays(t *testing.T) {
|
||||
in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)))
|
||||
}
|
||||
now := time.Now()
|
||||
day1 := pickHeadAndTail(in, testUserID, "2026-05-07", now, 20, 5)
|
||||
day2 := pickHeadAndTail(in, testUserID, "2026-05-08", now, 20, 5)
|
||||
day1 := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", now, 20, 5)
|
||||
day2 := pickHeadAndTail(in, nil, 1, 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))
|
||||
}
|
||||
@@ -205,7 +205,7 @@ func TestPickHeadAndTail_TailFromBeyond2xHeadN(t *testing.T) {
|
||||
sim := float64(50 - i)
|
||||
in = append(in, makeCand(i+1, i+1, i+1, sim))
|
||||
}
|
||||
got := pickHeadAndTail(in, testUserID, "2026-05-07", time.Now(), 5, 3)
|
||||
got := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", time.Now(), 5, 3)
|
||||
if len(got) != 8 {
|
||||
t.Fatalf("len = %d, want 8 (5 head + 3 tail)", len(got))
|
||||
}
|
||||
@@ -251,7 +251,7 @@ func TestPickHeadAndTail_MarksPickKinds(t *testing.T) {
|
||||
for i := 0; i < 100; i++ {
|
||||
in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)))
|
||||
}
|
||||
got := pickHeadAndTail(in, testUserID, "2026-05-07", time.Now(), 20, 5)
|
||||
got := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", time.Now(), 20, 5)
|
||||
if len(got) != 25 {
|
||||
t.Fatalf("len = %d, want 25", len(got))
|
||||
}
|
||||
@@ -275,7 +275,7 @@ func TestPickHeadAndTail_SmallPoolAllTaste(t *testing.T) {
|
||||
makeCand(2, 11, 101, 0.9),
|
||||
makeCand(3, 12, 102, 0.8),
|
||||
}
|
||||
got := pickHeadAndTail(in, testUserID, "2026-05-07", time.Now(), 5, 2)
|
||||
got := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", time.Now(), 5, 2)
|
||||
for i, rc := range got {
|
||||
if rc.PickKind != pickKindTaste {
|
||||
t.Errorf("got[%d].PickKind = %q, want %q (fallback is all taste)",
|
||||
@@ -293,3 +293,140 @@ func TestPickTopN_NoPickKind(t *testing.T) {
|
||||
t.Errorf("pickTopN PickKind = %q, want empty (persists as NULL)", got[0].PickKind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadQuotas(t *testing.T) {
|
||||
cases := []struct {
|
||||
seeds, headN int
|
||||
want []int
|
||||
}{
|
||||
{1, 50, []int{50}},
|
||||
{2, 50, []int{30, 20}},
|
||||
{3, 50, []int{25, 15, 10}},
|
||||
{3, 7, []int{4, 2, 1}}, // rounding remainder → primary seed
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := headQuotas(c.seeds, c.headN)
|
||||
if len(got) != len(c.want) {
|
||||
t.Fatalf("headQuotas(%d, %d) len = %d, want %d", c.seeds, c.headN, len(got), len(c.want))
|
||||
}
|
||||
sum := 0
|
||||
for i := range got {
|
||||
sum += got[i]
|
||||
if got[i] != c.want[i] {
|
||||
t.Errorf("headQuotas(%d, %d)[%d] = %d, want %d", c.seeds, c.headN, i, got[i], c.want[i])
|
||||
}
|
||||
}
|
||||
if sum != c.headN {
|
||||
t.Errorf("headQuotas(%d, %d) sums to %d, want %d", c.seeds, c.headN, sum, c.headN)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickQuotaHead_BlendsSeeds(t *testing.T) {
|
||||
// Seed 0's neighborhood out-scores everything (#1269): without
|
||||
// quotas the head would be all seed-0. With headN=10 and quotas
|
||||
// [5,3,2], the walk takes the best 5 from seed 0, then seeds 1 and
|
||||
// 2 get their guaranteed slots from deeper ranks.
|
||||
in := make([]recommendation.Candidate, 0, 30)
|
||||
seedOf := map[pgtype.UUID]int{}
|
||||
for i := 0; i < 30; i++ {
|
||||
c := makeCand(i+1, i+1, i+1, float64(30-i))
|
||||
si := 0
|
||||
if i >= 20 && i < 25 {
|
||||
si = 1
|
||||
} else if i >= 25 {
|
||||
si = 2
|
||||
}
|
||||
seedOf[c.Track.ID] = si
|
||||
in = append(in, c)
|
||||
}
|
||||
// Input is already score-descending and cap-free.
|
||||
head := pickQuotaHead(in, seedOf, 3, 10)
|
||||
if len(head) != 10 {
|
||||
t.Fatalf("head len = %d, want 10", len(head))
|
||||
}
|
||||
want := []byte{1, 2, 3, 4, 5, 21, 22, 23, 26, 27}
|
||||
for i, w := range want {
|
||||
if head[i].Track.ID.Bytes[15] != w {
|
||||
t.Errorf("head[%d] = track %d, want %d", i, head[i].Track.ID.Bytes[15], w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickQuotaHead_ThinSeedSpillsBestFirst(t *testing.T) {
|
||||
// Seed 1 has a single candidate; its unfilled quota goes to the
|
||||
// best remaining candidates regardless of seed.
|
||||
in := make([]recommendation.Candidate, 0, 12)
|
||||
seedOf := map[pgtype.UUID]int{}
|
||||
for i := 0; i < 12; i++ {
|
||||
c := makeCand(i+1, i+1, i+1, float64(12-i))
|
||||
si := 0
|
||||
if i == 11 {
|
||||
si = 1 // the worst-ranked candidate is seed 1's only one
|
||||
}
|
||||
seedOf[c.Track.ID] = si
|
||||
in = append(in, c)
|
||||
}
|
||||
head := pickQuotaHead(in, seedOf, 2, 10)
|
||||
if len(head) != 10 {
|
||||
t.Fatalf("head len = %d, want 10", len(head))
|
||||
}
|
||||
// Quotas [6,4]: seed 0 fills 6 (tracks 1-6), seed 1 fills 1
|
||||
// (track 12), spill tops up with tracks 7-9.
|
||||
got := map[byte]bool{}
|
||||
for _, c := range head {
|
||||
got[c.Track.ID.Bytes[15]] = true
|
||||
}
|
||||
for _, w := range []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 12} {
|
||||
if !got[w] {
|
||||
t.Errorf("head missing track %d (spill should fill best-first)", w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickWeightedTail_PrefersEarlyRanks(t *testing.T) {
|
||||
// The fresh tail's sample is rank-weighted (#1269): across many
|
||||
// days, the mean selected rank must sit well below the uniform
|
||||
// draw's ~99.5 for a 200-deep pool.
|
||||
pool := make([]recommendation.Candidate, 0, 200)
|
||||
for i := 0; i < 200; i++ {
|
||||
c := makeCand(1, i+1, i+1, 1.0)
|
||||
// Distinct track IDs beyond one byte: bytes wrap at 256.
|
||||
c.Track.ID.Bytes[14] = byte(i / 200)
|
||||
c.Track.ID.Bytes[15] = byte(i % 200)
|
||||
pool = append(pool, c)
|
||||
}
|
||||
rankOf := map[pgtype.UUID]int{}
|
||||
for i, c := range pool {
|
||||
rankOf[c.Track.ID] = i
|
||||
}
|
||||
totalRank, picks := 0, 0
|
||||
for day := 1; day <= 28; day++ {
|
||||
got := pickWeightedTail(pool, "2026-06-"+twoDigits(day), 10)
|
||||
if len(got) != 10 {
|
||||
t.Fatalf("day %d: len = %d, want 10", day, len(got))
|
||||
}
|
||||
for _, c := range got {
|
||||
totalRank += rankOf[c.Track.ID]
|
||||
picks++
|
||||
}
|
||||
}
|
||||
mean := float64(totalRank) / float64(picks)
|
||||
if mean > 80 {
|
||||
t.Errorf("mean selected rank = %.1f; want well below uniform ~99.5", mean)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickWeightedTail_DeterministicWithinDay(t *testing.T) {
|
||||
pool := make([]recommendation.Candidate, 0, 60)
|
||||
for i := 0; i < 60; i++ {
|
||||
pool = append(pool, makeCand(i+1, i+1, i+1, 1.0))
|
||||
}
|
||||
a := pickWeightedTail(pool, "2026-06-05", 8)
|
||||
b := pickWeightedTail(pool, "2026-06-05", 8)
|
||||
for i := range a {
|
||||
if a[i].Track.ID != b[i].Track.ID {
|
||||
t.Fatal("weighted tail must be deterministic within a day")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,60 +96,65 @@ func TestUserIDHash_DifferentUserChangesHash(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// pickDailySeeds shuffles the candidate pool daily-deterministically
|
||||
// and takes up to n — For-You uses n=forYouSeedCount for its
|
||||
// multi-seed blend (#1269), Songs-like uses n=3 via the
|
||||
// pickSeedArtistsForDay wrapper. Verifies determinism within a day,
|
||||
// variation across days, and graceful degradation on small pools.
|
||||
|
||||
func TestPickForYouSeedForDay_DeterministicWithinDay(t *testing.T) {
|
||||
func TestPickDailySeeds_DeterministicWithinDay(t *testing.T) {
|
||||
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
||||
seeds := []pgtype.UUID{
|
||||
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 := 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)
|
||||
a := pickDailySeeds(pool, u, "2026-05-04", 3)
|
||||
b := pickDailySeeds(pool, u, "2026-05-04", 3)
|
||||
if len(a) != 3 || len(b) != 3 {
|
||||
t.Fatalf("lens = %d, %d; want 3, 3", len(a), len(b))
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
t.Fatalf("same day should pick same seeds; got %v then %v", a, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickForYouSeedForDay_VariesAcrossDays(t *testing.T) {
|
||||
func TestPickDailySeeds_VariesAcrossDays(t *testing.T) {
|
||||
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
||||
seeds := []pgtype.UUID{
|
||||
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},
|
||||
}
|
||||
picks := map[[16]byte]bool{}
|
||||
firstPicks := map[[16]byte]bool{}
|
||||
for i := 1; i <= 30; i++ {
|
||||
date := "2026-05-" + twoDigits(i)
|
||||
picks[pickForYouSeedForDay(seeds, u, date).Bytes] = true
|
||||
firstPicks[pickDailySeeds(pool, u, date, 3)[0].Bytes] = true
|
||||
}
|
||||
if len(picks) < 2 {
|
||||
t.Errorf("expected >=2 distinct seeds across 30 days; got %d", len(picks))
|
||||
if len(firstPicks) < 2 {
|
||||
t.Errorf("expected >=2 distinct lead seeds across 30 days; got %d", len(firstPicks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickForYouSeedForDay_SingleCandidate(t *testing.T) {
|
||||
func TestPickDailySeeds_SmallPool(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)
|
||||
got := pickDailySeeds([]pgtype.UUID{only}, u, "2026-05-04", 3)
|
||||
if len(got) != 1 || got[0] != only {
|
||||
t.Errorf("single-entry pool should return just that entry; got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickForYouSeedForDay_EmptyPool(t *testing.T) {
|
||||
func TestPickDailySeeds_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)
|
||||
if got := pickDailySeeds(nil, u, "2026-05-04", 3); len(got) != 0 {
|
||||
t.Errorf("empty pool should return nothing; got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+192
-62
@@ -12,6 +12,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"math/rand"
|
||||
"sort"
|
||||
"time"
|
||||
@@ -83,32 +84,15 @@ func userIDHash(userID pgtype.UUID, dateStr string) uint64 {
|
||||
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).
|
||||
// pickDailySeeds takes a candidate pool of UUIDs and returns up to n
|
||||
// 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
|
||||
// Degrades gracefully: <n candidates returns whatever is available in
|
||||
// shuffled order.
|
||||
func pickSeedArtistsForDay(pool []pgtype.UUID, userID pgtype.UUID, dateStr string) []pgtype.UUID {
|
||||
func pickDailySeeds(pool []pgtype.UUID, userID pgtype.UUID, dateStr string, n int) []pgtype.UUID {
|
||||
if len(pool) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -118,13 +102,18 @@ func pickSeedArtistsForDay(pool []pgtype.UUID, userID pgtype.UUID, dateStr strin
|
||||
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]
|
||||
}
|
||||
|
||||
// pickSeedArtistsForDay picks up to 3 seed artists for the Songs-like
|
||||
// mixes; For-You uses pickDailySeeds directly with forYouSeedCount.
|
||||
func pickSeedArtistsForDay(pool []pgtype.UUID, userID pgtype.UUID, dateStr string) []pgtype.UUID {
|
||||
return pickDailySeeds(pool, userID, dateStr, 3)
|
||||
}
|
||||
|
||||
// rankedCandidate is a (track_id, score) pair used during in-memory
|
||||
// sorting before insert into playlist_tracks. T5 fills these from
|
||||
// recommendation.Candidate scores. PickKind is the track's provenance
|
||||
@@ -226,6 +215,122 @@ const (
|
||||
forYouTailN = 50
|
||||
)
|
||||
|
||||
// forYouSeedCount is how many of the user's top-5 tracks seed each
|
||||
// day's build (#1269). A single rotating seed made the mix bipolar
|
||||
// day-to-day — swinging whole neighborhoods as the rotation moved
|
||||
// between dissimilar anchors; blending 3 spans neighborhoods within
|
||||
// one day and smooths the swing across days.
|
||||
const forYouSeedCount = 3
|
||||
|
||||
// headQuotas fixes each seed's share of the For-You head so one
|
||||
// neighborhood can't monopolize it even when its candidates out-score
|
||||
// the others (#1269): 50/30/20 for three seeds, 60/40 for two, all of
|
||||
// it for one. Rounding remainder goes to the primary seed; a thin
|
||||
// seed's unfilled quota spills best-first in pickQuotaHead.
|
||||
func headQuotas(numSeeds, headN int) []int {
|
||||
if numSeeds <= 1 {
|
||||
return []int{headN}
|
||||
}
|
||||
fractions := []float64{0.5, 0.3, 0.2}
|
||||
if numSeeds == 2 {
|
||||
fractions = []float64{0.6, 0.4}
|
||||
}
|
||||
if numSeeds > len(fractions) {
|
||||
// Not reachable at forYouSeedCount = 3; even split keeps any
|
||||
// future seed-count change from silently starving seeds.
|
||||
fractions = make([]float64, numSeeds)
|
||||
for i := range fractions {
|
||||
fractions[i] = 1.0 / float64(numSeeds)
|
||||
}
|
||||
}
|
||||
quotas := make([]int, numSeeds)
|
||||
assigned := 0
|
||||
for i := 0; i < numSeeds; i++ {
|
||||
quotas[i] = int(float64(headN) * fractions[i])
|
||||
assigned += quotas[i]
|
||||
}
|
||||
quotas[0] += headN - assigned
|
||||
return quotas
|
||||
}
|
||||
|
||||
// pickQuotaHead walks the score-sorted capped pool best-first, taking
|
||||
// candidates whose originating seed still has head quota; slots a thin
|
||||
// seed can't fill spill best-first regardless of seed in a second
|
||||
// pass. A nil seedOf (single-seed path, tests) attributes everything
|
||||
// to seed 0, which reduces to plain top-headN.
|
||||
func pickQuotaHead(capped []recommendation.Candidate, seedOf map[pgtype.UUID]int, numSeeds, headN int) []recommendation.Candidate {
|
||||
quotas := headQuotas(numSeeds, headN)
|
||||
taken := make([]int, len(quotas))
|
||||
inHead := make(map[pgtype.UUID]bool, headN)
|
||||
head := make([]recommendation.Candidate, 0, headN)
|
||||
for _, c := range capped {
|
||||
if len(head) >= headN {
|
||||
break
|
||||
}
|
||||
si := seedOf[c.Track.ID]
|
||||
if si < 0 || si >= len(quotas) {
|
||||
si = 0
|
||||
}
|
||||
if taken[si] >= quotas[si] {
|
||||
continue
|
||||
}
|
||||
taken[si]++
|
||||
head = append(head, c)
|
||||
inHead[c.Track.ID] = true
|
||||
}
|
||||
for _, c := range capped {
|
||||
if len(head) >= headN {
|
||||
break
|
||||
}
|
||||
if inHead[c.Track.ID] {
|
||||
continue
|
||||
}
|
||||
head = append(head, c)
|
||||
inHead[c.Track.ID] = true
|
||||
}
|
||||
return head
|
||||
}
|
||||
|
||||
// forYouTailHalfLifeRanks tunes the fresh tail's rank bias (#1269): a
|
||||
// candidate's sampling weight halves every 50 ranks, so the 101st-best
|
||||
// candidate is far likelier than the 401st-best instead of equal —
|
||||
// freshness keeps its "you'll probably enjoy this" half.
|
||||
const forYouTailHalfLifeRanks = 50.0
|
||||
|
||||
// tailSampleUniverse quantizes tieBreakHash into a uniform (0,1] draw
|
||||
// for the weighted sample; 1e6 buckets is plenty of resolution for
|
||||
// pools of a few hundred.
|
||||
const tailSampleUniverse uint64 = 1_000_000
|
||||
|
||||
// pickWeightedTail samples up to tailN candidates from the rank-
|
||||
// ordered tail pool with exponentially rank-decaying weights, using
|
||||
// deterministic Efraimidis-Spirakis keys (u^(1/w), u derived from
|
||||
// tieBreakHash): same (track, day) → same key, so the sample is stable
|
||||
// within a day and rotates across days. Replaces the uniform daily
|
||||
// draw where rank 380 had the same chance as rank 101.
|
||||
func pickWeightedTail(tailPool []recommendation.Candidate, dateStr string, tailN int) []recommendation.Candidate {
|
||||
if len(tailPool) <= tailN {
|
||||
return tailPool
|
||||
}
|
||||
type keyed struct {
|
||||
c recommendation.Candidate
|
||||
key float64
|
||||
}
|
||||
keys := make([]keyed, len(tailPool))
|
||||
for i, c := range tailPool {
|
||||
u := (float64(tieBreakHash(c.Track.ID, dateStr)%tailSampleUniverse) + 1) /
|
||||
float64(tailSampleUniverse+1)
|
||||
w := math.Exp2(-float64(i) / forYouTailHalfLifeRanks)
|
||||
keys[i] = keyed{c: c, key: math.Pow(u, 1/w)}
|
||||
}
|
||||
sort.SliceStable(keys, func(i, j int) bool { return keys[i].key > keys[j].key })
|
||||
out := make([]recommendation.Candidate, tailN)
|
||||
for i := 0; i < tailN; i++ {
|
||||
out[i] = keys[i].c
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// scoreAndSortCandidates scores every candidate with recommendation.Score
|
||||
// and returns a new slice sorted by score DESC (ties broken by
|
||||
// tieBreakHash). The scoring RNG is seeded by userIDHash so jitter is
|
||||
@@ -387,10 +492,11 @@ func systemForYouSourceLimits() recommendation.CandidateSourceLimits {
|
||||
}
|
||||
}
|
||||
|
||||
// produceForYou: today's seed from the user's top-5 played tracks
|
||||
// (rotates daily via userIDHash), similarity candidate pool, head+
|
||||
// tail composition. The base seed query failing is fatal; a
|
||||
// candidate-load failure is logged and yields no For-You.
|
||||
// produceForYou: blend candidate pools from up to forYouSeedCount of
|
||||
// the user's top-5 played tracks (rotating daily via pickDailySeeds),
|
||||
// then head+tail composition with per-seed head quotas (#1269). The
|
||||
// base seed query failing is fatal; a per-seed candidate-load failure
|
||||
// is logged and that seed just contributes nothing.
|
||||
func produceForYou(
|
||||
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
|
||||
userID pgtype.UUID, dateStr string, now time.Time,
|
||||
@@ -399,24 +505,40 @@ func produceForYou(
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pick for-you seed candidates: %w", err)
|
||||
}
|
||||
forYouSeed := pickForYouSeedForDay(forYouSeeds, userID, dateStr)
|
||||
if !forYouSeed.Valid {
|
||||
seeds := pickDailySeeds(forYouSeeds, userID, dateStr, forYouSeedCount)
|
||||
if len(seeds) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
zeroVec := recommendation.SessionVector{Seed: true}
|
||||
cands, cerr := recommendation.LoadCandidatesFromSimilarity(
|
||||
ctx, q, userID, forYouSeed,
|
||||
1, // recentlyPlayedHours — small to avoid filtering the seed's recent neighbourhood
|
||||
zeroVec,
|
||||
[]pgtype.UUID{forYouSeed},
|
||||
systemForYouSourceLimits(),
|
||||
)
|
||||
if cerr != nil {
|
||||
logger.Warn("system playlist: for-you candidates load failed; skipping",
|
||||
"user_id", uuidStringPL(userID), "err", cerr)
|
||||
// Merge per-seed pools; first-seen wins on dedup, and seedOf
|
||||
// remembers which seed sourced each track for the head quotas.
|
||||
var merged []recommendation.Candidate
|
||||
seedOf := map[pgtype.UUID]int{}
|
||||
for i, seed := range seeds {
|
||||
cands, cerr := recommendation.LoadCandidatesFromSimilarity(
|
||||
ctx, q, userID, seed,
|
||||
1, // recentlyPlayedHours — small to avoid filtering the seed's recent neighbourhood
|
||||
zeroVec,
|
||||
seeds,
|
||||
systemForYouSourceLimits(),
|
||||
)
|
||||
if cerr != nil {
|
||||
logger.Warn("system playlist: for-you candidates load failed for seed; continuing",
|
||||
"user_id", uuidStringPL(userID), "seed", uuidStringPL(seed), "err", cerr)
|
||||
continue
|
||||
}
|
||||
for _, c := range cands {
|
||||
if _, seen := seedOf[c.Track.ID]; seen {
|
||||
continue
|
||||
}
|
||||
seedOf[c.Track.ID] = i
|
||||
merged = append(merged, c)
|
||||
}
|
||||
}
|
||||
if len(merged) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
tracks := pickHeadAndTail(cands, userID, dateStr, now, forYouHeadN, forYouTailN)
|
||||
tracks := pickHeadAndTail(merged, seedOf, len(seeds), userID, dateStr, now, forYouHeadN, forYouTailN)
|
||||
if len(tracks) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -672,10 +794,11 @@ func pickTopN(cands []recommendation.Candidate, userID pgtype.UUID, dateStr stri
|
||||
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.
|
||||
// pickHeadAndTail picks headN taste anchors from the score-sorted pool
|
||||
// (under per-seed quotas when seedOf/numSeeds describe a multi-seed
|
||||
// blend, #1269) plus tailN freshness picks sampled rank-weighted from
|
||||
// positions 2*headN onward. 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
|
||||
@@ -685,7 +808,10 @@ func pickTopN(cands []recommendation.Candidate, userID pgtype.UUID, dateStr stri
|
||||
// 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, userID pgtype.UUID, dateStr string, now time.Time, headN, tailN int) []rankedCandidate {
|
||||
func pickHeadAndTail(
|
||||
cands []recommendation.Candidate, seedOf map[pgtype.UUID]int, numSeeds int,
|
||||
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))))
|
||||
@@ -709,30 +835,34 @@ func pickHeadAndTail(cands []recommendation.Candidate, userID pgtype.UUID, dateS
|
||||
return out
|
||||
}
|
||||
|
||||
head := capped[:headN]
|
||||
head := pickQuotaHead(capped, seedOf, numSeeds, headN)
|
||||
inHead := make(map[pgtype.UUID]bool, len(head))
|
||||
for _, c := range head {
|
||||
inHead[c.Track.ID] = true
|
||||
}
|
||||
|
||||
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]
|
||||
// The tail pool keeps its rank order (position drives the sampling
|
||||
// weight); head members are excluded — a quota walk can reach past
|
||||
// tailStart when a seed's candidates rank deep.
|
||||
tailPool := make([]recommendation.Candidate, 0, len(capped)-tailStart)
|
||||
for _, c := range capped[tailStart:] {
|
||||
if inHead[c.Track.ID] {
|
||||
continue
|
||||
}
|
||||
tailPool = append(tailPool, c)
|
||||
}
|
||||
tail := pickWeightedTail(tailPool, dateStr, tailN)
|
||||
|
||||
// Combine: head order preserved (score-sorted), tail in tieBreakHash
|
||||
// order. "First similar, then surprise" reads naturally in playback.
|
||||
// Head entries are the taste picks; tail entries are the freshness
|
||||
// injection (#1249) — the split the metrics page attributes skips to.
|
||||
// Combine: head first (score-sorted under quotas), then the fresh
|
||||
// sample. "First similar, then surprise" reads naturally in
|
||||
// playback. Head entries are the taste picks; tail entries are the
|
||||
// freshness injection (#1249) — the split the metrics page
|
||||
// attributes skips to.
|
||||
combined := make([]recommendation.Candidate, 0, len(head)+len(tail))
|
||||
combined = append(combined, head...)
|
||||
combined = append(combined, tail...)
|
||||
|
||||
@@ -83,10 +83,14 @@ func buildYouMightLike(
|
||||
"user_id", uuidStringPL(userID), "err", err)
|
||||
return youMightLikeResult{built: false}
|
||||
}
|
||||
seed := pickForYouSeedForDay(seeds, userID, dateStr)
|
||||
if !seed.Valid {
|
||||
// One rotating seed is right here (unlike For-You's multi-seed
|
||||
// blend, #1269): the row is a short shelf, not a mix, and a single
|
||||
// neighborhood per day keeps it coherent.
|
||||
daily := pickDailySeeds(seeds, userID, dateStr, 1)
|
||||
if len(daily) == 0 {
|
||||
return youMightLikeResult{built: true}
|
||||
}
|
||||
seed := daily[0]
|
||||
|
||||
zeroVec := recommendation.SessionVector{Seed: true}
|
||||
// You-might-like surfaces in-library artists the user does NOT actively
|
||||
|
||||
Reference in New Issue
Block a user