Files
minstrel/internal/playlists/foryou_test.go
T
bvandeusen 5cd342d521 feat(playlists): daily seed rotation + jitter + 12+13 split for system playlists
Five diversity mechanics — applied to both For-You and Songs-Like-X.

1. For-You seed rotates daily across the user's top-5 most-played
   tracks. pickForYouSeedForDay uses userIDHash(user, day) mod
   len(seeds) so today's mix uses an entirely different similarity
   pool than tomorrow's. Within-day determinism preserved.
2. JitterMagnitude bumped 0.0 → 0.1. The scoring RNG is now seeded
   by userIDHash(user, day) rather than the no-op, so near-tied
   candidates shuffle daily without breaking within-day stability.
3. Head/tail split moves from 20+5 to 12+13. Roughly half the
   playlist comes from the tail now (daily-deterministic via
   tieBreakHash), giving the user substantially different content
   while a 12-track anchor of strong similarity matches keeps the
   mix recognizable.
4. Songs-Like-X seed artists shuffle daily across the user's top-5
   played artists. pickSeedArtistsForDay applies a userIDHash-seeded
   Fisher-Yates and takes 3.
5. scoreAndSortCandidates / pickTopN / pickHeadAndTail gain a userID
   parameter so the RNG can be seeded per-user; existing call sites
   updated; noopRNG removed.

Test fixtures widened similarity gaps (e.g. float64(50-i) instead of
(50-i)/50) so the new jitter (±0.1) doesn't perturb head ordering in
assertions about the head/tail mechanism. New seed_selection_test
coverage for userIDHash + pickForYouSeedForDay + pickSeedArtistsForDay
spans deterministic-within-day, varies-across-days, and graceful
degradation with small candidate pools.

PickTopPlayedTrackForUser replaced by PickTopPlayedTracksForUser
:many in the prior commit (b4801c2). The For-You seed lookup now
goes through pickForYouSeedForDay over the returned slice.
PickSeedArtists's LIMIT widened to 5 in the same prior commit.

For #392 Half A — system playlist content diversity. Half B
(per-user timezone scheduling) is a separate spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:19:49 -04:00

246 lines
8.5 KiB
Go

package playlists
import (
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
)
// testUserID is a stable UUID used to seed the daily-determinism RNGs in
// scoreAndSortCandidates / pickHeadAndTail / pickTopN. Tests pass it to
// every call so the jitter is reproducible across test runs.
var testUserID = pgtype.UUID{Bytes: [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}, Valid: true}
// makeCand constructs a Candidate with distinct track/album/artist IDs
// and a SimilarityScore that drives distinguishable scores when passed
// through recommendation.Score. Using SimilarityScore alone is sufficient
// because systemMixWeights.SimilarityWeight = 1.5 (non-zero), so
// different similarity values produce different scores.
//
// Tests below use similarity values with gaps wide enough that the
// systemMixWeights JitterMagnitude (±0.1) cannot perturb the top-of-pool
// ordering, so assertions about head order remain meaningful.
func makeCand(trackN, albumN, artistN int, similarity float64) recommendation.Candidate {
var tID, aID, arID pgtype.UUID
tID.Valid, aID.Valid, arID.Valid = true, true, true
tID.Bytes[15] = byte(trackN)
aID.Bytes[15] = byte(albumN)
arID.Bytes[15] = byte(artistN)
return recommendation.Candidate{
Track: dbq.Track{ID: tID, AlbumID: aID, ArtistID: arID},
Inputs: recommendation.ScoringInputs{
SimilarityScore: similarity,
},
}
}
func TestCapCandidatesByAlbumAndArtist_AlbumCap(t *testing.T) {
// discoverMaxTracksPerAlbum = 2: third track on album 10 must be dropped.
in := []recommendation.Candidate{
makeCand(1, 10, 100, 1.0),
makeCand(2, 10, 101, 0.9),
makeCand(3, 10, 102, 0.8), // 3rd from album 10 → drop
makeCand(4, 11, 103, 0.7),
}
got := capCandidatesByAlbumAndArtist(in)
if len(got) != 3 {
t.Errorf("len = %d, want 3 (album 10 capped at 2)", len(got))
}
}
func TestCapCandidatesByAlbumAndArtist_ArtistCap(t *testing.T) {
// discoverMaxTracksPerArtist = 3: fourth track by artist 100 must be dropped.
in := []recommendation.Candidate{
makeCand(1, 10, 100, 1.0),
makeCand(2, 11, 100, 0.9),
makeCand(3, 12, 100, 0.8),
makeCand(4, 13, 100, 0.7), // 4th by artist 100 → drop
makeCand(5, 14, 101, 0.6),
}
got := capCandidatesByAlbumAndArtist(in)
if len(got) != 4 {
t.Errorf("len = %d, want 4 (artist 100 capped at 3)", len(got))
}
}
func TestCapCandidatesByAlbumAndArtist_PreservesOrder(t *testing.T) {
// All distinct albums and artists: all kept, original order preserved.
in := []recommendation.Candidate{
makeCand(3, 10, 100, 0.5),
makeCand(1, 11, 101, 0.9),
makeCand(2, 12, 102, 0.7),
}
got := capCandidatesByAlbumAndArtist(in)
if len(got) != 3 {
t.Fatalf("len = %d, want 3", len(got))
}
for i, want := range []byte{3, 1, 2} {
if got[i].Track.ID.Bytes[15] != want {
t.Errorf("got[%d].ID = %d, want %d", i, got[i].Track.ID.Bytes[15], want)
}
}
}
func TestCapCandidatesByAlbumAndArtist_Empty(t *testing.T) {
got := capCandidatesByAlbumAndArtist(nil)
if len(got) != 0 {
t.Errorf("len = %d, want 0", len(got))
}
}
func TestPickHeadAndTail_SmallPool(t *testing.T) {
// Pool < headN+tailN → return up to total entries, no tail split.
in := []recommendation.Candidate{
makeCand(1, 10, 100, 1.0),
makeCand(2, 11, 101, 0.9),
makeCand(3, 12, 102, 0.8),
}
got := pickHeadAndTail(in, 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))
}
}
func TestPickHeadAndTail_ExactlyTotal(t *testing.T) {
// Pool == headN+tailN: no tail to sample from, returns all.
// Wide score gaps (1.0 per step) keep jitter from perturbing order.
in := make([]recommendation.Candidate, 0, 7)
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)
if len(got) != 7 {
t.Errorf("len = %d, want 7 (pool == total)", len(got))
}
}
func TestPickHeadAndTail_HeadAndTailSplit(t *testing.T) {
// Pool of 100 distinct (album, artist) pairs; no caps trim.
// With headN=20, tailN=5, expect 25 entries total.
// Wide score gaps (1.0 per step) keep jitter from perturbing order.
in := make([]recommendation.Candidate, 0, 100)
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)
if len(got) != 25 {
t.Errorf("len = %d, want 25 (20 head + 5 tail)", len(got))
}
}
func TestPickHeadAndTail_Determinism(t *testing.T) {
// Same inputs, same dateStr → identical output both times.
in := make([]recommendation.Candidate, 0, 100)
for i := 0; i < 100; i++ {
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)
if len(got1) != len(got2) {
t.Fatalf("len mismatch: %d vs %d", len(got1), len(got2))
}
for i := range got1 {
if got1[i].TrackID.Bytes[15] != got2[i].TrackID.Bytes[15] {
t.Errorf("position %d differs across calls (not deterministic within day)", i)
break
}
}
}
func TestPickHeadAndTail_HeadStable_TailVariesAcrossDays(t *testing.T) {
// Head (positions 0..headN-1) must match across different date strings.
// Tail (positions headN..headN+tailN-1) should differ for different dates
// because tieBreakHash incorporates the date.
in := make([]recommendation.Candidate, 0, 100)
for i := 0; i < 100; i++ {
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)
if len(day1) != 25 || len(day2) != 25 {
t.Fatalf("len mismatch: day1=%d day2=%d", len(day1), len(day2))
}
// Head (score-sorted, deterministic): positions 0..19 must match.
for i := 0; i < 20; i++ {
if day1[i].TrackID.Bytes[15] != day2[i].TrackID.Bytes[15] {
t.Errorf("head position %d differs across date strings (should be score-stable)", i)
}
}
// Tail (tieBreakHash order): at least one position should differ.
tailDiffers := false
for i := 20; i < 25; i++ {
if day1[i].TrackID.Bytes[15] != day2[i].TrackID.Bytes[15] {
tailDiffers = true
break
}
}
if !tailDiffers {
t.Errorf("tail identical across different date strings (tieBreakHash not using dateStr)")
}
}
func TestPickHeadAndTail_TailFromBeyond2xHeadN(t *testing.T) {
// With headN=5 and a 50-candidate pool, tailStart = 2*5 = 10.
// Tail samples come from positions >=10, not from positions 5..9.
// Verify: the first 5 positions contain tracks from the top-5 by score,
// and positions 5..9 (the "buffer zone") do not appear in the tail.
//
// Tracks 1..50 have similarity 0.99..0.01 (descending), so
// after scoring the sorted order is tracks 1,2,3,...,50.
// Top 5 head = tracks 1..5.
// Buffer zone (positions 5..9) = tracks 6..10.
// Tail pool = tracks 11..50; tail sample = 3 from that pool.
in := make([]recommendation.Candidate, 0, 50)
for i := 0; i < 50; i++ {
// trackN = i+1, score descends: track 1 scores highest.
// Wide score gaps (1.0 per step) keep jitter from perturbing order.
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)
if len(got) != 8 {
t.Fatalf("len = %d, want 8 (5 head + 3 tail)", len(got))
}
// Head tracks must be tracks 1..5 (highest scorers).
for i := 0; i < 5; i++ {
if got[i].TrackID.Bytes[15] != byte(i+1) {
t.Errorf("head[%d] = track %d, want track %d",
i, got[i].TrackID.Bytes[15], i+1)
}
}
// Buffer-zone tracks 6..10 must NOT appear in the tail positions 5..7.
bufferSet := map[byte]bool{6: true, 7: true, 8: true, 9: true, 10: true}
for i := 5; i < 8; i++ {
if bufferSet[got[i].TrackID.Bytes[15]] {
t.Errorf("tail[%d] = track %d (buffer zone); should come from positions >=10",
i-5, got[i].TrackID.Bytes[15])
}
}
}
func TestPickTopN_DiversityCap(t *testing.T) {
// Verify pickTopN now enforces the diversity cap.
// 5 tracks from the same artist: only 3 should survive the cap.
in := []recommendation.Candidate{
makeCand(1, 10, 100, 1.0),
makeCand(2, 11, 100, 0.9),
makeCand(3, 12, 100, 0.8),
makeCand(4, 13, 100, 0.7), // 4th by artist → dropped by cap
makeCand(5, 14, 100, 0.6), // 5th by artist → dropped by cap
}
got := pickTopN(in, testUserID, "2026-05-07", time.Now(), 25)
if len(got) != 3 {
t.Errorf("len = %d, want 3 (artist 100 capped at 3)", len(got))
}
}