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