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:
+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...)
|
||||
|
||||
Reference in New Issue
Block a user