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, 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)) } } 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, nil, 1, 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, 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)) } } 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, 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)) } 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, 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)) } // 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, 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)) } // 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)) } } func TestPickHeadAndTail_MarksPickKinds(t *testing.T) { // 100-deep pool with headN=20, tailN=5: the 20 head entries are the // taste picks, the 5 tail entries the freshness injection (#1249). 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, nil, 1, testUserID, "2026-05-07", time.Now(), 20, 5) if len(got) != 25 { t.Fatalf("len = %d, want 25", len(got)) } for i := 0; i < 20; i++ { if got[i].PickKind != pickKindTaste { t.Errorf("head[%d].PickKind = %q, want %q", i, got[i].PickKind, pickKindTaste) } } for i := 20; i < 25; i++ { if got[i].PickKind != pickKindFresh { t.Errorf("tail[%d].PickKind = %q, want %q", i-20, got[i].PickKind, pickKindFresh) } } } func TestPickHeadAndTail_SmallPoolAllTaste(t *testing.T) { // The small-pool fallback is pure top-N-by-score — that IS the taste // mechanism, so nothing on this path is an exploration pick. in := []recommendation.Candidate{ makeCand(1, 10, 100, 1.0), makeCand(2, 11, 101, 0.9), makeCand(3, 12, 102, 0.8), } 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)", i, rc.PickKind, pickKindTaste) } } } func TestPickTopN_NoPickKind(t *testing.T) { // Songs-like-X and the discovery mixes don't split; their rows must // persist pick_kind NULL (empty string here). in := []recommendation.Candidate{makeCand(1, 10, 100, 1.0)} got := pickTopN(in, testUserID, "2026-05-07", time.Now(), 25) if len(got) != 1 || got[0].PickKind != "" { 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") } } }