Files
bvandeusen fdd14ef04c
test-go / test (push) Successful in 39s
test-go / integration (push) Failing after 4m39s
feat(server): "You might like" album/artist Home rows (#790)
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>
2026-06-11 19:33:46 -04:00

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))
}
}