feat(server): "You might like" album/artist Home rows (#790)
test-go / test (push) Successful in 39s
test-go / integration (push) Failing after 4m39s

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>
This commit is contained in:
2026-06-11 19:33:46 -04:00
parent 4ecb1680bf
commit fdd14ef04c
12 changed files with 919 additions and 1 deletions
+14
View File
@@ -497,6 +497,13 @@ func BuildSystemPlaylists(ctx context.Context, pool *pgxpool.Pool, logger *slog.
built = append(built, out...)
}
// "You might like" Home rows reuse the same similarity engine but
// persist to dedicated album/artist tables (not playlist_tracks).
// Computed here (reads only) and atomic-replaced inside the tx below,
// alongside the system playlists. A failed/gated computation is
// handled by yml.built (leave prior rows vs. clear) — never fatal.
yml := buildYouMightLike(ctx, q, logger, userID, dateStr, now)
// Atomic replace inside a transaction.
tx, err := pool.Begin(ctx)
if err != nil {
@@ -530,6 +537,13 @@ func BuildSystemPlaylists(ctx context.Context, pool *pgxpool.Pool, logger *slog.
createdIDs = append(createdIDs, id)
}
if yml.built {
if err := persistYouMightLike(ctx, qtx, userID, yml); err != nil {
buildErr = fmt.Errorf("persist you-might-like: %w", err)
return buildErr
}
}
if err := tx.Commit(ctx); err != nil {
buildErr = fmt.Errorf("commit tx: %w", err)
return buildErr
+227
View File
@@ -0,0 +1,227 @@
// you_might_like.go builds the per-user "You might like" Home rows
// (#790): in-library albums/artists the listener doesn't actively spin
// but is predicted to enjoy. It reuses the For-You candidate engine
// (similarity + like-weighted scoring) and rolls the per-track scores up
// to album/artist granularity, gated on a minimum listening history so a
// near-empty profile ships nothing rather than noise.
//
// Built in the same daily BuildSystemPlaylists pass and atomic-replaced
// alongside the system playlists; read back by /api/home.
package playlists
import (
"context"
"fmt"
"log/slog"
"math/rand"
"sort"
"time"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
)
const (
// Cold-start gate. Below this much *real* listening the similarity
// roll-up degrades toward random fill, so You-might-like ships
// nothing rather than arbitrary tiles. Both bars must clear: distinct
// tracks rules out "played 3 songs on repeat," distinct artists rules
// out "hammered one album." Conservative defaults; raise if early
// rows feel random on thin libraries.
youMightLikeMinDistinctTracks = 20
youMightLikeMinDistinctArtists = 5
// Persisted depth — a few more than the rendered HomeYouMightLikeLimit
// so the read-time cross-section dedup (vs Most Played / Rediscover)
// has headroom before trimming.
youMightLikeAlbumsN = 12
youMightLikeArtistsN = 12
// One strongly-matched artist shouldn't fill the albums row.
youMightLikeMaxAlbumsPerArtist = 2
// Aggregation: sum of an entity's top-K track scores. Favors albums/
// artists with several good matches over a single outlier track.
youMightLikeAggTopK = 3
)
// youMightLikeResult carries one day's roll-up. built=false means the
// computation failed and the caller must leave any existing rows intact;
// built=true with nil slices means "explicitly empty" (cold-start gate or
// no candidates) and the caller should clear the user's rows.
type youMightLikeResult struct {
albumIDs []pgtype.UUID
artistIDs []pgtype.UUID
built bool
}
// buildYouMightLike computes the user's daily album/artist rolls. Reads
// only — the caller persists inside the build tx. Never returns an error:
// a failure logs and yields built=false so the prior day's rows survive.
func buildYouMightLike(
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
userID pgtype.UUID, dateStr string, now time.Time,
) youMightLikeResult {
signal, err := q.CountListeningSignalForUser(ctx, userID)
if err != nil {
logger.Warn("you-might-like: listening-signal query failed; skipping",
"user_id", uuidStringPL(userID), "err", err)
return youMightLikeResult{built: false}
}
if signal.DistinctTracks < youMightLikeMinDistinctTracks ||
signal.DistinctArtists < youMightLikeMinDistinctArtists {
// Cold start: not enough listening to recommend from. built=true
// clears any stale rows (normally none) and ships an empty row.
return youMightLikeResult{built: true}
}
seeds, err := q.PickTopPlayedTracksForUser(ctx, userID)
if err != nil {
logger.Warn("you-might-like: seed query failed; skipping",
"user_id", uuidStringPL(userID), "err", err)
return youMightLikeResult{built: false}
}
seed := pickForYouSeedForDay(seeds, userID, dateStr)
if !seed.Valid {
return youMightLikeResult{built: true}
}
zeroVec := recommendation.SessionVector{Seed: true}
cands, err := recommendation.LoadCandidatesFromSimilarity(
ctx, q, userID, seed, 1, zeroVec,
[]pgtype.UUID{seed}, systemForYouSourceLimits(),
)
if err != nil {
logger.Warn("you-might-like: candidate load failed; skipping",
"user_id", uuidStringPL(userID), "err", err)
return youMightLikeResult{built: false}
}
albumIDs, artistIDs := rollUpCandidates(cands, userID, dateStr, now)
return youMightLikeResult{albumIDs: albumIDs, artistIDs: artistIDs, built: true}
}
// scoredEntity is an album or artist with its aggregated score.
type scoredEntity struct {
id pgtype.UUID
score float64
}
// rollUpCandidates scores every candidate track (systemMixWeights, jitter
// seeded by userIDHash so near-ties rotate day-to-day) and aggregates the
// scores up to album and artist via sum-of-top-K. Returns the top-N album
// and artist IDs, with a per-artist cap on the album list.
func rollUpCandidates(
cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time,
) (albumIDs, artistIDs []pgtype.UUID) {
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
albumScores := map[pgtype.UUID][]float64{}
artistScores := map[pgtype.UUID][]float64{}
albumArtist := map[pgtype.UUID]pgtype.UUID{}
for _, c := range cands {
s := recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64)
if c.Track.AlbumID.Valid {
albumScores[c.Track.AlbumID] = append(albumScores[c.Track.AlbumID], s)
albumArtist[c.Track.AlbumID] = c.Track.ArtistID
}
if c.Track.ArtistID.Valid {
artistScores[c.Track.ArtistID] = append(artistScores[c.Track.ArtistID], s)
}
}
rankedAlbums := capAlbumsPerArtist(
rankEntities(albumScores, dateStr), albumArtist, youMightLikeMaxAlbumsPerArtist)
albumIDs = topNEntityIDs(rankedAlbums, youMightLikeAlbumsN)
artistIDs = topNEntityIDs(rankEntities(artistScores, dateStr), youMightLikeArtistsN)
return albumIDs, artistIDs
}
// rankEntities aggregates each entity's track scores (sum-of-top-K) and
// returns them sorted by score DESC, ties broken deterministically by
// tieBreakHash(id, dateStr) so ordering is stable within a day.
func rankEntities(scoresByEntity map[pgtype.UUID][]float64, dateStr string) []scoredEntity {
out := make([]scoredEntity, 0, len(scoresByEntity))
for id, scores := range scoresByEntity {
out = append(out, scoredEntity{id: id, score: sumTopK(scores, youMightLikeAggTopK)})
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].score != out[j].score {
return out[i].score > out[j].score
}
return tieBreakHash(out[i].id, dateStr) < tieBreakHash(out[j].id, dateStr)
})
return out
}
// sumTopK sorts scores DESC and sums the highest k.
func sumTopK(scores []float64, k int) float64 {
sort.Sort(sort.Reverse(sort.Float64Slice(scores)))
sum := 0.0
for i := 0; i < len(scores) && i < k; i++ {
sum += scores[i]
}
return sum
}
// capAlbumsPerArtist drops albums beyond maxPerArtist for any one artist,
// preserving input (score) order.
func capAlbumsPerArtist(
albums []scoredEntity, albumArtist map[pgtype.UUID]pgtype.UUID, maxPerArtist int,
) []scoredEntity {
perArtist := map[pgtype.UUID]int{}
out := make([]scoredEntity, 0, len(albums))
for _, a := range albums {
art := albumArtist[a.id]
if art.Valid {
if perArtist[art] >= maxPerArtist {
continue
}
perArtist[art]++
}
out = append(out, a)
}
return out
}
// topNEntityIDs truncates to n and projects the IDs in rank order.
func topNEntityIDs(entities []scoredEntity, n int) []pgtype.UUID {
if len(entities) > n {
entities = entities[:n]
}
out := make([]pgtype.UUID, 0, len(entities))
for _, e := range entities {
out = append(out, e.id)
}
return out
}
// persistYouMightLike atomic-replaces the user's You-might-like rows
// inside the build tx. Called only when the roll-up was freshly built;
// a failed computation leaves the prior rows untouched.
func persistYouMightLike(
ctx context.Context, qtx *dbq.Queries, userID pgtype.UUID, r youMightLikeResult,
) error {
if err := qtx.DeleteYouMightLikeAlbumsForUser(ctx, userID); err != nil {
return fmt.Errorf("delete you-might-like albums: %w", err)
}
for i, id := range r.albumIDs {
if err := qtx.InsertYouMightLikeAlbum(ctx, dbq.InsertYouMightLikeAlbumParams{
UserID: userID, AlbumID: id, Rank: int32(i),
}); err != nil {
return fmt.Errorf("insert you-might-like album: %w", err)
}
}
if err := qtx.DeleteYouMightLikeArtistsForUser(ctx, userID); err != nil {
return fmt.Errorf("delete you-might-like artists: %w", err)
}
for i, id := range r.artistIDs {
if err := qtx.InsertYouMightLikeArtist(ctx, dbq.InsertYouMightLikeArtistParams{
UserID: userID, ArtistID: id, Rank: int32(i),
}); err != nil {
return fmt.Errorf("insert you-might-like artist: %w", err)
}
}
return nil
}
@@ -0,0 +1,99 @@
package playlists_test
import (
"context"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
)
// countYouMightLike returns the persisted row counts for a user.
func countYouMightLike(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID) (albums, artists int) {
t.Helper()
ctx := context.Background()
if err := pool.QueryRow(ctx,
`SELECT count(*) FROM you_might_like_albums WHERE user_id = $1`, userID,
).Scan(&albums); err != nil {
t.Fatalf("count albums: %v", err)
}
if err := pool.QueryRow(ctx,
`SELECT count(*) FROM you_might_like_artists WHERE user_id = $1`, userID,
).Scan(&artists); err != nil {
t.Fatalf("count artists: %v", err)
}
return albums, artists
}
// TestYouMightLike_ColdStartGate: a thin profile (3 artists × 3 tracks =
// 9 distinct tracks, below the 20-track / 5-artist gate) must produce no
// You-might-like rows — the build ships nothing rather than near-random
// tiles.
func TestYouMightLike_ColdStartGate(t *testing.T) {
pool := newPool(t)
logger := discardLogger()
u, _ := seedActiveLibrary(t, pool, "ymlcold", 3, 3)
if err := playlists.BuildSystemPlaylists(
context.Background(), pool, logger, u.ID, time.Now().UTC(), t.TempDir(),
); err != nil {
t.Fatalf("build: %v", err)
}
albums, artists := countYouMightLike(t, pool, u.ID)
if albums != 0 || artists != 0 {
t.Errorf("cold-start user should have no you-might-like rows; got %d albums, %d artists",
albums, artists)
}
}
// TestYouMightLike_SufficientActivityPopulates: a rich profile (8 artists
// × 4 tracks = 32 distinct tracks, clearing both gate bars) populates the
// You-might-like tables in the daily build.
func TestYouMightLike_SufficientActivityPopulates(t *testing.T) {
pool := newPool(t)
logger := discardLogger()
u, _ := seedActiveLibrary(t, pool, "ymlrich", 8, 4)
if err := playlists.BuildSystemPlaylists(
context.Background(), pool, logger, u.ID, time.Now().UTC(), t.TempDir(),
); err != nil {
t.Fatalf("build: %v", err)
}
albums, artists := countYouMightLike(t, pool, u.ID)
if albums == 0 {
t.Error("rich user should have you-might-like album rows; got 0")
}
if artists == 0 {
t.Error("rich user should have you-might-like artist rows; got 0")
}
}
// TestYouMightLike_AtomicReplace: a second build replaces rather than
// appends, so counts stay bounded by the persisted depth.
func TestYouMightLike_AtomicReplace(t *testing.T) {
pool := newPool(t)
logger := discardLogger()
u, _ := seedActiveLibrary(t, pool, "ymlreplace", 8, 4)
ctx := context.Background()
for i := 0; i < 2; i++ {
if err := playlists.BuildSystemPlaylists(
ctx, pool, logger, u.ID, time.Now().UTC(), t.TempDir(),
); err != nil {
t.Fatalf("build %d: %v", i, err)
}
}
albums, artists := countYouMightLike(t, pool, u.ID)
if albums > 12 {
t.Errorf("albums = %d after two builds, want <= 12 (atomic replace)", albums)
}
if artists > 12 {
t.Errorf("artists = %d after two builds, want <= 12 (atomic replace)", artists)
}
}
+125
View File
@@ -0,0 +1,125 @@
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))
}
}