From 14debe2ea9b3a2bd2b574a28368298511f16f33c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 6 Nov 2026 14:21:04 -0500 Subject: [PATCH] feat: genre mixes lean toward what the listener likes (#6921) Within a genre both halves of the mix were a uniform daily draw: a song played forty times was no likelier than one played once, and the unheard 40% ignored who the artist was. ListGenreMixTracks now draws each half by a date-seeded weighted lottery (Efraimidis-Spirakis: smallest -ln(u)/weight wins), so the mix still changes daily and holds within a day. - Familiar: 1 + ln(1 + plays) + 1 if liked, times the share of plays that weren't skips. - Unheard: 1 + ln(1 + the artist's plays + 2 x its liked tracks) + 2 x the artist's best similarity to the user's own artists, times the artist's non-skip share. An unknown artist weighs 1, so new artists still come up. Which genres are offered, the exclusions and the familiar/unheard blend are unchanged. DB tests check the lean averaged over ten days, and that a stranger's tracks stay in the pool. Co-Authored-By: Claude Opus 5.5 --- internal/db/dbq/genre_mixes.sql.go | 72 +++++++++++++-- internal/db/queries/genre_mixes.sql | 72 +++++++++++++-- internal/playlists/genre_mixes.go | 5 +- internal/playlists/genre_mixes_db_test.go | 102 ++++++++++++++++++++++ 4 files changed, 240 insertions(+), 11 deletions(-) diff --git a/internal/db/dbq/genre_mixes.sql.go b/internal/db/dbq/genre_mixes.sql.go index 9f92aaca..8163d723 100644 --- a/internal/db/dbq/genre_mixes.sql.go +++ b/internal/db/dbq/genre_mixes.sql.go @@ -131,14 +131,57 @@ skip_counts AS ( WHERE pe.user_id = $1 AND pe.was_skipped = true GROUP BY pe.track_id ), +artist_plays AS ( + SELECT t.artist_id, + COUNT(*) FILTER (WHERE NOT pe.was_skipped) AS plays, + COUNT(*) FILTER (WHERE pe.was_skipped) AS skips + FROM play_events pe + JOIN tracks t ON t.id = pe.track_id + WHERE pe.user_id = $1 + GROUP BY t.artist_id +), +artist_likes AS ( + SELECT t.artist_id, COUNT(*) AS likes + FROM general_likes gl + JOIN tracks t ON t.id = gl.track_id + WHERE gl.user_id = $1 + GROUP BY t.artist_id +), +own_artists AS ( + SELECT ap.artist_id FROM artist_plays ap WHERE ap.plays > 0 + UNION + SELECT al.artist_id FROM artist_likes al +), +artist_sim AS ( + SELECT asim.artist_b_id AS artist_id, + MAX(LEAST(GREATEST(asim.score, 0), 1)) AS sim + FROM artist_similarity asim + JOIN own_artists oa ON oa.artist_id = asim.artist_a_id + GROUP BY asim.artist_b_id +), eligible AS ( SELECT t.id, t.album_id, t.artist_id, (COALESCE(pc.c, 0) > 0 OR gl.track_id IS NOT NULL) AS familiar, - md5(t.id::text || $2::text) AS tiebreak + CASE + WHEN COALESCE(pc.c, 0) > 0 OR gl.track_id IS NOT NULL THEN + (1 + ln(1 + COALESCE(pc.c, 0)::float8) + + CASE WHEN gl.track_id IS NOT NULL THEN 1 ELSE 0 END) + * ((COALESCE(pc.c, 0) + 1)::float8 / (COALESCE(pc.c, 0) + COALESCE(sc.c, 0) + 1)) + ELSE + (1 + ln(1 + COALESCE(ap.plays, 0)::float8 + 2 * COALESCE(al.likes, 0)::float8) + + 2 * COALESCE(asm.sim, 0)) + * ((COALESCE(ap.plays, 0) + 1)::float8 / (COALESCE(ap.plays, 0) + COALESCE(ap.skips, 0) + 1)) + END::float8 AS weight, + -- u in (0, 1): the first 32 bits of md5(track, date), shifted off 0. + ((('x' || substr(md5(t.id::text || $2::text), 1, 8))::bit(32)::bigint)::float8 + 1) + / 4294967297.0 AS u FROM tracks t LEFT JOIN play_counts pc ON pc.track_id = t.id LEFT JOIN skip_counts sc ON sc.track_id = t.id LEFT JOIN general_likes gl ON gl.user_id = $1 AND gl.track_id = t.id + LEFT JOIN artist_plays ap ON ap.artist_id = t.artist_id + LEFT JOIN artist_likes al ON al.artist_id = t.artist_id + LEFT JOIN artist_sim asm ON asm.artist_id = t.artist_id WHERE t.missing_since IS NULL -- never offer a file that is gone AND NOT (COALESCE(sc.c, 0) >= 2 AND COALESCE(sc.c, 0) > COALESCE(pc.c, 0)) AND EXISTS ( @@ -152,13 +195,15 @@ eligible AS ( ) ), ranked AS ( - SELECT e.id, e.album_id, e.artist_id, e.familiar, e.tiebreak, row_number() OVER (PARTITION BY e.familiar ORDER BY e.tiebreak) AS rn + SELECT e.id, e.album_id, e.artist_id, e.familiar, + -ln(e.u) / e.weight AS draw, + row_number() OVER (PARTITION BY e.familiar ORDER BY -ln(e.u) / e.weight, e.id) AS rn FROM eligible e ) SELECT r.id, r.album_id, r.artist_id, r.familiar::bool AS familiar FROM ranked r WHERE r.rn <= 200 - ORDER BY r.familiar DESC, r.tiebreak + ORDER BY r.familiar DESC, r.draw, r.id ` type ListGenreMixTracksParams struct { @@ -176,8 +221,25 @@ type ListGenreMixTracksRow struct { // Playable tracks carrying one genre, split into the two halves a genre mix // blends: familiar (the user has played it through or liked it) and unheard. -// Up to 200 of each, each half in a daily-deterministic order so the mix -// changes day to day and holds still within a day. +// Up to 200 of each, drawn by a weighted lottery seeded with the date, so the +// mix changes day to day, holds still within a day, and leans toward what the +// user likes without becoming the same 50 songs every day. +// +// The lottery is the Efraimidis-Spirakis draw: each track gets +// -ln(u) / weight with u uniform in (0, 1) from md5(track, date), and the +// smallest keys win. A track of weight 3 is three times as likely as one of +// weight 1 to come before it, and every track keeps some chance. +// +// Familiar weight: 1 + ln(1 + plays through) + 1 if liked, times the share of +// its plays that were not skips. A song played 40 times weighs about 4.7, one +// played once about 1.7. +// +// Unheard weight: 1 + ln(1 + the artist's plays through + 2 × its liked +// tracks) + 2 × how similar the artist is to the user's own artists (the best +// artist_similarity score from any artist they have played through or liked, +// 0 to 1), times the share of the artist's plays that were not skips. An +// artist the user has never met weighs 1, so new artists still come up; an +// artist they keep skipping weighs well under 1. // // A track the user keeps skipping stays out: twice-skipped and skipped more // often than played through. The second half of that test keeps a favourite diff --git a/internal/db/queries/genre_mixes.sql b/internal/db/queries/genre_mixes.sql index 58881e89..330ed094 100644 --- a/internal/db/queries/genre_mixes.sql +++ b/internal/db/queries/genre_mixes.sql @@ -73,8 +73,25 @@ SELECT lg.genre_key::text AS genre_key, -- name: ListGenreMixTracks :many -- Playable tracks carrying one genre, split into the two halves a genre mix -- blends: familiar (the user has played it through or liked it) and unheard. --- Up to 200 of each, each half in a daily-deterministic order so the mix --- changes day to day and holds still within a day. +-- Up to 200 of each, drawn by a weighted lottery seeded with the date, so the +-- mix changes day to day, holds still within a day, and leans toward what the +-- user likes without becoming the same 50 songs every day. +-- +-- The lottery is the Efraimidis-Spirakis draw: each track gets +-- -ln(u) / weight with u uniform in (0, 1) from md5(track, date), and the +-- smallest keys win. A track of weight 3 is three times as likely as one of +-- weight 1 to come before it, and every track keeps some chance. +-- +-- Familiar weight: 1 + ln(1 + plays through) + 1 if liked, times the share of +-- its plays that were not skips. A song played 40 times weighs about 4.7, one +-- played once about 1.7. +-- +-- Unheard weight: 1 + ln(1 + the artist's plays through + 2 × its liked +-- tracks) + 2 × how similar the artist is to the user's own artists (the best +-- artist_similarity score from any artist they have played through or liked, +-- 0 to 1), times the share of the artist's plays that were not skips. An +-- artist the user has never met weighs 1, so new artists still come up; an +-- artist they keep skipping weighs well under 1. -- -- A track the user keeps skipping stays out: twice-skipped and skipped more -- often than played through. The second half of that test keeps a favourite @@ -91,14 +108,57 @@ skip_counts AS ( WHERE pe.user_id = sqlc.arg(user_id) AND pe.was_skipped = true GROUP BY pe.track_id ), +artist_plays AS ( + SELECT t.artist_id, + COUNT(*) FILTER (WHERE NOT pe.was_skipped) AS plays, + COUNT(*) FILTER (WHERE pe.was_skipped) AS skips + FROM play_events pe + JOIN tracks t ON t.id = pe.track_id + WHERE pe.user_id = sqlc.arg(user_id) + GROUP BY t.artist_id +), +artist_likes AS ( + SELECT t.artist_id, COUNT(*) AS likes + FROM general_likes gl + JOIN tracks t ON t.id = gl.track_id + WHERE gl.user_id = sqlc.arg(user_id) + GROUP BY t.artist_id +), +own_artists AS ( + SELECT ap.artist_id FROM artist_plays ap WHERE ap.plays > 0 + UNION + SELECT al.artist_id FROM artist_likes al +), +artist_sim AS ( + SELECT asim.artist_b_id AS artist_id, + MAX(LEAST(GREATEST(asim.score, 0), 1)) AS sim + FROM artist_similarity asim + JOIN own_artists oa ON oa.artist_id = asim.artist_a_id + GROUP BY asim.artist_b_id +), eligible AS ( SELECT t.id, t.album_id, t.artist_id, (COALESCE(pc.c, 0) > 0 OR gl.track_id IS NOT NULL) AS familiar, - md5(t.id::text || sqlc.arg(date_seed)::text) AS tiebreak + CASE + WHEN COALESCE(pc.c, 0) > 0 OR gl.track_id IS NOT NULL THEN + (1 + ln(1 + COALESCE(pc.c, 0)::float8) + + CASE WHEN gl.track_id IS NOT NULL THEN 1 ELSE 0 END) + * ((COALESCE(pc.c, 0) + 1)::float8 / (COALESCE(pc.c, 0) + COALESCE(sc.c, 0) + 1)) + ELSE + (1 + ln(1 + COALESCE(ap.plays, 0)::float8 + 2 * COALESCE(al.likes, 0)::float8) + + 2 * COALESCE(asm.sim, 0)) + * ((COALESCE(ap.plays, 0) + 1)::float8 / (COALESCE(ap.plays, 0) + COALESCE(ap.skips, 0) + 1)) + END::float8 AS weight, + -- u in (0, 1): the first 32 bits of md5(track, date), shifted off 0. + ((('x' || substr(md5(t.id::text || sqlc.arg(date_seed)::text), 1, 8))::bit(32)::bigint)::float8 + 1) + / 4294967297.0 AS u FROM tracks t LEFT JOIN play_counts pc ON pc.track_id = t.id LEFT JOIN skip_counts sc ON sc.track_id = t.id LEFT JOIN general_likes gl ON gl.user_id = sqlc.arg(user_id) AND gl.track_id = t.id + LEFT JOIN artist_plays ap ON ap.artist_id = t.artist_id + LEFT JOIN artist_likes al ON al.artist_id = t.artist_id + LEFT JOIN artist_sim asm ON asm.artist_id = t.artist_id WHERE t.missing_since IS NULL -- never offer a file that is gone AND NOT (COALESCE(sc.c, 0) >= 2 AND COALESCE(sc.c, 0) > COALESCE(pc.c, 0)) AND EXISTS ( @@ -112,10 +172,12 @@ eligible AS ( ) ), ranked AS ( - SELECT e.*, row_number() OVER (PARTITION BY e.familiar ORDER BY e.tiebreak) AS rn + SELECT e.id, e.album_id, e.artist_id, e.familiar, + -ln(e.u) / e.weight AS draw, + row_number() OVER (PARTITION BY e.familiar ORDER BY -ln(e.u) / e.weight, e.id) AS rn FROM eligible e ) SELECT r.id, r.album_id, r.artist_id, r.familiar::bool AS familiar FROM ranked r WHERE r.rn <= 200 - ORDER BY r.familiar DESC, r.tiebreak; + ORDER BY r.familiar DESC, r.draw, r.id; diff --git a/internal/playlists/genre_mixes.go b/internal/playlists/genre_mixes.go index 82fbf8ac..cde3969c 100644 --- a/internal/playlists/genre_mixes.go +++ b/internal/playlists/genre_mixes.go @@ -22,7 +22,10 @@ import ( // ten channels of music they never play. // // The set of genres is stable from day to day, like a list of channels; the -// tracks inside each mix change daily. +// tracks inside each mix change daily. ListGenreMixTracks draws them by a +// date-seeded weighted lottery: familiar tracks lean toward the ones played +// most and liked, unheard ones toward the user's artists and artists similar +// to them, and every track keeps some chance. const ( // genreMixCount is the most genre mixes a user gets. diff --git a/internal/playlists/genre_mixes_db_test.go b/internal/playlists/genre_mixes_db_test.go index 11cc0d90..efc9d628 100644 --- a/internal/playlists/genre_mixes_db_test.go +++ b/internal/playlists/genre_mixes_db_test.go @@ -200,3 +200,105 @@ func TestBuildSystemPlaylists_GenreMixes(t *testing.T) { t.Errorf("pick kinds = %v, want both taste and taste_unheard", kinds) } } + +// genreDrawPositions runs ListGenreMixTracks for ten different days and +// returns, per track, the sum of its positions within its own half +// (familiar or unheard). The draw is random per day, so a lean shows up as +// an earlier average position across days, not in any single one. +func genreDrawPositions(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID, genreKey string) map[pgtype.UUID]int { + t.Helper() + sum := map[pgtype.UUID]int{} + for day := 1; day <= 10; day++ { + rows, err := dbq.New(pool).ListGenreMixTracks(context.Background(), dbq.ListGenreMixTracksParams{ + UserID: userID, DateSeed: fmt.Sprintf("2026-11-%02d", day), GenreKey: genreKey, + }) + if err != nil { + t.Fatalf("list tracks: %v", err) + } + pos := map[bool]int{} + for _, r := range rows { + sum[r.ID] += pos[r.Familiar] + pos[r.Familiar]++ + } + } + return sum +} + +func meanPosition(sum map[pgtype.UUID]int, tracks []dbq.Track) float64 { + total := 0 + for _, tr := range tracks { + total += sum[tr.ID] + } + return float64(total) / float64(len(tracks)) +} + +// TestListGenreMixTracks_FamiliarLeansTowardFavourites: among the tracks the +// user knows, ones played often and liked come up earlier than ones played +// once. +func TestListGenreMixTracks_FamiliarLeansTowardFavourites(t *testing.T) { + pool := newPool(t) + ctx := context.Background() + u := seedUser(t, pool, "gmixfav") + now := time.Now().UTC() + + tracks := seedGenre(t, pool, "Krautrock", 4, 5) + favourites, onceOnly := tracks[:10], tracks[10:] + for i, tr := range favourites { + for k := 0; k < 5; k++ { + seedPlayEvent(t, pool, u.ID, tr.ID, now.Add(-time.Duration(i*10+k+1)*time.Hour), false) + } + if _, err := pool.Exec(ctx, `INSERT INTO general_likes (user_id, track_id) VALUES ($1, $2)`, u.ID, tr.ID); err != nil { + t.Fatalf("like: %v", err) + } + } + for i, tr := range onceOnly { + seedPlayEvent(t, pool, u.ID, tr.ID, now.Add(-time.Duration(200+i)*time.Hour), false) + } + + sum := genreDrawPositions(t, pool, u.ID, "krautrock") + fav, once := meanPosition(sum, favourites), meanPosition(sum, onceOnly) + if fav >= once { + t.Errorf("favourites' summed position %.1f, played-once %.1f: want favourites earlier", fav, once) + } +} + +// TestListGenreMixTracks_UnheardLeansTowardTheUsersArtists: among unheard +// tracks, an artist the user plays and likes comes up earlier than one they +// have never met, and so does an artist similar to theirs. The stranger +// still appears: the lean tilts the draw, it does not exclude. +func TestListGenreMixTracks_UnheardLeansTowardTheUsersArtists(t *testing.T) { + pool := newPool(t) + ctx := context.Background() + u := seedUser(t, pool, "gmixartist") + now := time.Now().UTC() + + tracks := seedGenre(t, pool, "Shoegaze", 3, 12) + loved, similar, stranger := tracks[:12], tracks[12:24], tracks[24:] + for k := 0; k < 15; k++ { + seedPlayEvent(t, pool, u.ID, loved[0].ID, now.Add(-time.Duration(k+1)*time.Hour), false) + } + for _, tr := range loved[1:3] { + if _, err := pool.Exec(ctx, `INSERT INTO general_likes (user_id, track_id) VALUES ($1, $2)`, u.ID, tr.ID); err != nil { + t.Fatalf("like: %v", err) + } + } + if _, err := pool.Exec(ctx, ` + INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source) + VALUES ($1, $2, 0.9, 'listenbrainz')`, loved[0].ArtistID, similar[0].ArtistID); err != nil { + t.Fatalf("seed similarity: %v", err) + } + + sum := genreDrawPositions(t, pool, u.ID, "shoegaze") + l, s, x := meanPosition(sum, loved[3:]), meanPosition(sum, similar), meanPosition(sum, stranger) + if l >= x { + t.Errorf("loved artist's unheard tracks at %.1f, stranger's at %.1f: want the loved artist earlier", l, x) + } + if s >= x { + t.Errorf("similar artist's tracks at %.1f, stranger's at %.1f: want the similar artist earlier", s, x) + } + for _, tr := range stranger { + if _, in := sum[tr.ID]; !in { + t.Fatalf("a stranger's track was left out of the pool; the lean must not exclude") + } + } +}