feat(playlists): For You composition v2 — multi-seed blend + weighted fresh tail
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 4m37s

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:
2026-07-03 09:02:35 -04:00
parent 48f288e2e5
commit 9e02878b61
6 changed files with 382 additions and 106 deletions
+4 -4
View File
@@ -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 {
+4 -4
View File
@@ -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
+147 -10
View File
@@ -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")
}
}
}
+29 -24
View File
@@ -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
View File
@@ -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...)
+6 -2
View File
@@ -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