fdd14ef04c
Surface in-library albums/artists the listener doesn't actively spin but is predicted to enjoy, derived from the same similarity + like-weighted candidate engine that powers For-You — rolled up from track scores to album/artist granularity. Built in the daily 3am BuildSystemPlaylists pass, atomic-replaced alongside the system playlists, and read back by /api/home (+ /api/home/index). Cold-start gate: skips generation entirely below 20 distinct unskipped tracks AND 5 distinct artists, so a thin profile ships empty rows rather than near-random tiles. - migration 0034: you_might_like_albums / you_might_like_artists (id+rank, CASCADE, per-user rank index). - playlists/you_might_like.go: cold-start gate + similarity roll-up (sum-of-top-3 aggregation, per-artist album cap, daily-rotating via the same userIDHash jitter as For-You) + atomic-replace persist in the tx. - recommendation/home.go: two new HomePayload sections with read-time cross-section dedup vs Most Played / Rediscover / Last Played, trimmed to 10 each. - api: you_might_like_albums / you_might_like_artists on /api/home and /api/home/index, reusing albumRefFrom / artistRefFromCovered. - tests: pure roll-up/aggregation/cap unit tests + DB-backed gate, sufficiency, and atomic-replace tests (all green vs real Postgres). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
126 lines
4.0 KiB
Go
126 lines
4.0 KiB
Go
package playlists
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
|
)
|
|
|
|
// fixedNow keeps recencyDecay stable across the pure roll-up tests.
|
|
var fixedNow = time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC)
|
|
|
|
// byteOf returns the per-test entity discriminator (makeCand sets it in
|
|
// Bytes[15]) so assertions can name albums/artists by their small int.
|
|
func byteOf(u pgtype.UUID) byte { return u.Bytes[15] }
|
|
|
|
// countWithByte reports how many of ids carry the given Bytes[15] marker.
|
|
func countWithByte(ids []pgtype.UUID, want byte) int {
|
|
n := 0
|
|
for _, id := range ids {
|
|
if byteOf(id) == want {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
func TestSumTopK(t *testing.T) {
|
|
if got := sumTopK([]float64{1, 2, 3, 4}, 2); got != 7 {
|
|
t.Errorf("sumTopK top-2 of 1..4 = %v, want 7", got)
|
|
}
|
|
if got := sumTopK([]float64{5}, 3); got != 5 {
|
|
t.Errorf("sumTopK fewer-than-k = %v, want 5", got)
|
|
}
|
|
if got := sumTopK(nil, 3); got != 0 {
|
|
t.Errorf("sumTopK empty = %v, want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestRollUpCandidates_MultiMatchAlbumRanksFirst(t *testing.T) {
|
|
// Album 10 (artist 100): three solid matches. Album 20 (artist 200):
|
|
// one slightly-stronger single match. Sum-of-top-3 should rank the
|
|
// multi-match album ahead of the single-match one. Similarity gaps are
|
|
// wide enough that the ±0.1 jitter can't reorder the result.
|
|
cands := []recommendation.Candidate{
|
|
makeCand(1, 10, 100, 0.8),
|
|
makeCand(2, 10, 100, 0.8),
|
|
makeCand(3, 10, 100, 0.8),
|
|
makeCand(4, 20, 200, 0.95),
|
|
}
|
|
albums, _ := rollUpCandidates(cands, testUserID, "2026-06-11", fixedNow)
|
|
if len(albums) < 2 {
|
|
t.Fatalf("want >=2 albums, got %d", len(albums))
|
|
}
|
|
if byteOf(albums[0]) != 10 {
|
|
t.Errorf("top album = %d, want 10 (multi-match beats single)", byteOf(albums[0]))
|
|
}
|
|
}
|
|
|
|
func TestRollUpCandidates_PerArtistAlbumCap(t *testing.T) {
|
|
// Artist 100 spans albums 10/11/12; the album row caps any one artist
|
|
// at youMightLikeMaxAlbumsPerArtist (2). Artist 200's album 20 should
|
|
// still appear.
|
|
cands := []recommendation.Candidate{
|
|
makeCand(1, 10, 100, 0.9),
|
|
makeCand(2, 11, 100, 0.85),
|
|
makeCand(3, 12, 100, 0.8),
|
|
makeCand(4, 20, 200, 0.7),
|
|
}
|
|
albums, _ := rollUpCandidates(cands, testUserID, "2026-06-11", fixedNow)
|
|
from100 := countWithByte(albums, 10) + countWithByte(albums, 11) + countWithByte(albums, 12)
|
|
if from100 > youMightLikeMaxAlbumsPerArtist {
|
|
t.Errorf("artist 100 contributed %d albums, want <= %d",
|
|
from100, youMightLikeMaxAlbumsPerArtist)
|
|
}
|
|
if countWithByte(albums, 20) != 1 {
|
|
t.Errorf("album 20 (artist 200) should survive the cap; got %d",
|
|
countWithByte(albums, 20))
|
|
}
|
|
}
|
|
|
|
func TestRollUpCandidates_ArtistRollupDistinct(t *testing.T) {
|
|
// Three artists; the artist roll-up should surface all three distinct
|
|
// artist IDs (no per-artist cap on the artist row).
|
|
cands := []recommendation.Candidate{
|
|
makeCand(1, 10, 100, 0.9),
|
|
makeCand(2, 11, 101, 0.6),
|
|
makeCand(3, 12, 102, 0.3),
|
|
}
|
|
_, artists := rollUpCandidates(cands, testUserID, "2026-06-11", fixedNow)
|
|
seen := map[byte]bool{}
|
|
for _, a := range artists {
|
|
seen[byteOf(a)] = true
|
|
}
|
|
for _, want := range []byte{100, 101, 102} {
|
|
if !seen[want] {
|
|
t.Errorf("artist %d missing from roll-up", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCapAlbumsPerArtist_DropsBeyondCap(t *testing.T) {
|
|
mk := func(albumN, artistN int) (pgtype.UUID, pgtype.UUID) {
|
|
var al, ar pgtype.UUID
|
|
al.Valid, ar.Valid = true, true
|
|
al.Bytes[15], ar.Bytes[15] = byte(albumN), byte(artistN)
|
|
return al, ar
|
|
}
|
|
a10, ar1 := mk(10, 1)
|
|
a11, _ := mk(11, 1)
|
|
a12, _ := mk(12, 1)
|
|
a20, ar2 := mk(20, 2)
|
|
albumArtist := map[pgtype.UUID]pgtype.UUID{a10: ar1, a11: ar1, a12: ar1, a20: ar2}
|
|
in := []scoredEntity{{a10, 3}, {a11, 2}, {a12, 1}, {a20, 0.5}}
|
|
got := capAlbumsPerArtist(in, albumArtist, 2)
|
|
if len(got) != 3 {
|
|
t.Fatalf("len = %d, want 3 (artist 1 capped at 2 + artist 2's one)", len(got))
|
|
}
|
|
// Highest-scored two of artist 1 (albums 10, 11) kept; 12 dropped.
|
|
if byteOf(got[2].id) != 20 {
|
|
t.Errorf("third survivor = %d, want 20", byteOf(got[2].id))
|
|
}
|
|
}
|