9e02878b61
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
261 lines
8.2 KiB
Go
261 lines
8.2 KiB
Go
package playlists
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
// pickSeedArtistsFromRows is the pure helper that turns sqlc rows into
|
|
// the seed list. The DB-level query is exercised in system_test.go's
|
|
// integration test; this unit test pins the post-fetch logic.
|
|
|
|
func TestPickSeedArtistsFromRows_PreservesOrder(t *testing.T) {
|
|
mk := func(b byte, score int64) seedArtistRow {
|
|
return seedArtistRow{
|
|
ArtistID: pgtype.UUID{Bytes: [16]byte{b}, Valid: true},
|
|
Score: score,
|
|
}
|
|
}
|
|
// Caller (SQL) already orders + limits; helper preserves order.
|
|
rows := []seedArtistRow{mk(1, 100), mk(2, 50), mk(3, 25)}
|
|
got := pickSeedArtistsFromRows(rows)
|
|
if len(got) != 3 {
|
|
t.Fatalf("len: got %d, want 3", len(got))
|
|
}
|
|
if got[0].Bytes[0] != 1 || got[1].Bytes[0] != 2 || got[2].Bytes[0] != 3 {
|
|
t.Errorf("expected [1,2,3] preserving input order; got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestPickSeedArtistsFromRows_FewerThanThree(t *testing.T) {
|
|
mk := func(b byte, score int64) seedArtistRow {
|
|
return seedArtistRow{ArtistID: pgtype.UUID{Bytes: [16]byte{b}, Valid: true}, Score: score}
|
|
}
|
|
rows := []seedArtistRow{mk(1, 100)}
|
|
got := pickSeedArtistsFromRows(rows)
|
|
if len(got) != 1 {
|
|
t.Errorf("len: got %d, want 1", len(got))
|
|
}
|
|
}
|
|
|
|
func TestPickSeedArtistsFromRows_Empty(t *testing.T) {
|
|
got := pickSeedArtistsFromRows(nil)
|
|
if len(got) != 0 {
|
|
t.Errorf("empty input should yield empty output; got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestPickKindForSeedTier(t *testing.T) {
|
|
// The seed query's fallback tiers map onto the rule-#131 ladder:
|
|
// fresh 7-day engagement is the exact desire, everything past the
|
|
// 30-day step-back collapses into the far tier (#1255).
|
|
cases := []struct {
|
|
tier int32
|
|
want string
|
|
}{
|
|
{0, pickKindTier1},
|
|
{1, pickKindTier2},
|
|
{2, pickKindTier3},
|
|
{3, pickKindTier3},
|
|
}
|
|
for _, c := range cases {
|
|
if got := pickKindForSeedTier(c.tier); got != c.want {
|
|
t.Errorf("pickKindForSeedTier(%d) = %q, want %q", c.tier, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// userIDHash is the per-user, per-day hash that drives the daily-
|
|
// determinism RNGs. Same family as tieBreakHash, just keyed on user
|
|
// ID instead of track ID.
|
|
|
|
func TestUserIDHash_Deterministic(t *testing.T) {
|
|
u := pgtype.UUID{Bytes: [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}, Valid: true}
|
|
a := userIDHash(u, "2026-05-04")
|
|
b := userIDHash(u, "2026-05-04")
|
|
if a != b {
|
|
t.Fatalf("same inputs gave different hashes: %d vs %d", a, b)
|
|
}
|
|
}
|
|
|
|
func TestUserIDHash_DifferentDateChangesHash(t *testing.T) {
|
|
u := pgtype.UUID{Bytes: [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}, Valid: true}
|
|
a := userIDHash(u, "2026-05-04")
|
|
b := userIDHash(u, "2026-05-05")
|
|
if a == b {
|
|
t.Errorf("different dates should change hash; both = %d", a)
|
|
}
|
|
}
|
|
|
|
func TestUserIDHash_DifferentUserChangesHash(t *testing.T) {
|
|
a := pgtype.UUID{Bytes: [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}, Valid: true}
|
|
b := pgtype.UUID{Bytes: [16]byte{16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1}, Valid: true}
|
|
if userIDHash(a, "2026-05-04") == userIDHash(b, "2026-05-04") {
|
|
t.Errorf("different users should change hash")
|
|
}
|
|
}
|
|
|
|
// 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 TestPickDailySeeds_DeterministicWithinDay(t *testing.T) {
|
|
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
|
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 := 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 TestPickDailySeeds_VariesAcrossDays(t *testing.T) {
|
|
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
|
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},
|
|
}
|
|
firstPicks := map[[16]byte]bool{}
|
|
for i := 1; i <= 30; i++ {
|
|
date := "2026-05-" + twoDigits(i)
|
|
firstPicks[pickDailySeeds(pool, u, date, 3)[0].Bytes] = true
|
|
}
|
|
if len(firstPicks) < 2 {
|
|
t.Errorf("expected >=2 distinct lead seeds across 30 days; got %d", len(firstPicks))
|
|
}
|
|
}
|
|
|
|
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 := 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 TestPickDailySeeds_EmptyPool(t *testing.T) {
|
|
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
|
if got := pickDailySeeds(nil, u, "2026-05-04", 3); len(got) != 0 {
|
|
t.Errorf("empty pool should return nothing; got %v", got)
|
|
}
|
|
}
|
|
|
|
// pickSeedArtistsForDay takes the user's top-5 candidate artists and
|
|
// returns 3 of them via daily-deterministic shuffle. Verifies the
|
|
// picker is deterministic within a day, varies across days, and
|
|
// degrades gracefully when fewer than 3 or 5 candidates exist.
|
|
|
|
func TestPickSeedArtistsForDay_DeterministicWithinDay(t *testing.T) {
|
|
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
|
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 := pickSeedArtistsForDay(pool, u, "2026-05-04")
|
|
b := pickSeedArtistsForDay(pool, u, "2026-05-04")
|
|
if len(a) != 3 || len(b) != 3 {
|
|
t.Fatalf("expected 3 seeds; got %d / %d", len(a), len(b))
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
t.Fatalf("same day should produce same order; differs at i=%d", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPickSeedArtistsForDay_VariesAcrossDays(t *testing.T) {
|
|
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
|
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},
|
|
}
|
|
// Collect the trio (as a sorted byte tuple) across many dates.
|
|
// With C(5,3) = 10 possible trios, 30 dates should yield >=2 distinct sets.
|
|
seen := map[[3]byte]bool{}
|
|
for i := 1; i <= 30; i++ {
|
|
got := pickSeedArtistsForDay(pool, u, "2026-05-"+twoDigits(i))
|
|
if len(got) != 3 {
|
|
t.Fatalf("expected 3 seeds; got %d", len(got))
|
|
}
|
|
// Sort the three byte values for set-equivalence comparison.
|
|
v := [3]byte{got[0].Bytes[0], got[1].Bytes[0], got[2].Bytes[0]}
|
|
if v[0] > v[1] {
|
|
v[0], v[1] = v[1], v[0]
|
|
}
|
|
if v[1] > v[2] {
|
|
v[1], v[2] = v[2], v[1]
|
|
}
|
|
if v[0] > v[1] {
|
|
v[0], v[1] = v[1], v[0]
|
|
}
|
|
seen[v] = true
|
|
}
|
|
if len(seen) < 2 {
|
|
t.Errorf("expected >=2 distinct seed trios across 30 days; got %d", len(seen))
|
|
}
|
|
}
|
|
|
|
func TestPickSeedArtistsForDay_FewerThanFive(t *testing.T) {
|
|
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
|
pool := []pgtype.UUID{
|
|
{Bytes: [16]byte{10}, Valid: true},
|
|
{Bytes: [16]byte{20}, Valid: true},
|
|
{Bytes: [16]byte{30}, Valid: true},
|
|
}
|
|
got := pickSeedArtistsForDay(pool, u, "2026-05-04")
|
|
if len(got) != 3 {
|
|
t.Errorf("with 3 candidates, expected all 3 returned; got %d", len(got))
|
|
}
|
|
}
|
|
|
|
func TestPickSeedArtistsForDay_FewerThanThree(t *testing.T) {
|
|
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
|
pool := []pgtype.UUID{
|
|
{Bytes: [16]byte{10}, Valid: true},
|
|
{Bytes: [16]byte{20}, Valid: true},
|
|
}
|
|
got := pickSeedArtistsForDay(pool, u, "2026-05-04")
|
|
if len(got) != 2 {
|
|
t.Errorf("with 2 candidates, expected 2 returned; got %d", len(got))
|
|
}
|
|
}
|
|
|
|
func TestPickSeedArtistsForDay_Empty(t *testing.T) {
|
|
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
|
got := pickSeedArtistsForDay(nil, u, "2026-05-04")
|
|
if len(got) != 0 {
|
|
t.Errorf("empty pool should return empty; got %d", len(got))
|
|
}
|
|
}
|
|
|
|
func twoDigits(n int) string {
|
|
if n < 10 {
|
|
return "0" + string(rune('0'+n))
|
|
}
|
|
return string(rune('0'+n/10)) + string(rune('0'+n%10))
|
|
}
|