diff --git a/internal/db/dbq/system_playlists.sql.go b/internal/db/dbq/system_playlists.sql.go index b7ba85eb..4f8d1a86 100644 --- a/internal/db/dbq/system_playlists.sql.go +++ b/internal/db/dbq/system_playlists.sql.go @@ -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 { diff --git a/internal/db/queries/system_playlists.sql b/internal/db/queries/system_playlists.sql index 63245abb..4f8a7696 100644 --- a/internal/db/queries/system_playlists.sql +++ b/internal/db/queries/system_playlists.sql @@ -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 diff --git a/internal/playlists/foryou_test.go b/internal/playlists/foryou_test.go index ab389136..2a102f9e 100644 --- a/internal/playlists/foryou_test.go +++ b/internal/playlists/foryou_test.go @@ -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") + } + } +} diff --git a/internal/playlists/seed_selection_test.go b/internal/playlists/seed_selection_test.go index 90aa35b9..e3399f3b 100644 --- a/internal/playlists/seed_selection_test.go +++ b/internal/playlists/seed_selection_test.go @@ -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) } } diff --git a/internal/playlists/system.go b/internal/playlists/system.go index 2ad4495a..3b68bc45 100644 --- a/internal/playlists/system.go +++ b/internal/playlists/system.go @@ -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: 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...) diff --git a/internal/playlists/you_might_like.go b/internal/playlists/you_might_like.go index 3d40e919..8d94cc77 100644 --- a/internal/playlists/you_might_like.go +++ b/internal/playlists/you_might_like.go @@ -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