From aa390c711aa367670b81729197a71c6566690e19 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 7 Oct 2026 19:56:21 -0400 Subject: [PATCH] fix(recommendation): artist-level arms take one track per related artist in turn (#5297) The similar_artists and coplay_artists arms ordered by artist score, so the closest related artist's catalogue filled the whole LIMIT. On the operator's library the 30-row similar_artists arm held exactly one artist for all 17 seeds measured (#3879), though each seed had 7-33 similar artists in the library. Both arms now rank tracks within each artist and take every artist's first track, best artist first, before anyone's second. Both also skip missing tracks: the outer select already dropped them, but only after they had taken places in the arm's LIMIT. Co-Authored-By: Claude Opus 5.5 --- internal/db/dbq/recommendation.sql.go | 46 ++++++++----- internal/db/queries/recommendation.sql | 46 ++++++++----- internal/recommendation/candidates_v2_test.go | 66 +++++++++++++++++++ 3 files changed, 128 insertions(+), 30 deletions(-) diff --git a/internal/db/dbq/recommendation.sql.go b/internal/db/dbq/recommendation.sql.go index 9ddd88b2..bab29ed0 100644 --- a/internal/db/dbq/recommendation.sql.go +++ b/internal/db/dbq/recommendation.sql.go @@ -828,13 +828,23 @@ lb_similar AS ( LIMIT $5 ), similar_artists AS ( - SELECT t.id AS track_id, asim.score * 0.5 AS sim_score - FROM artist_similarity asim - JOIN tracks t ON t.artist_id = asim.artist_b_id - JOIN seed_artist sa ON asim.artist_a_id = sa.artist_id - WHERE asim.source = 'listenbrainz' - AND t.id NOT IN (SELECT id FROM excluded_ids) - ORDER BY asim.score DESC, md5(t.id::text || $12::text) + -- Round-robin across the similar artists (#5297): each artist's first + -- track, best-scoring artist first, then each one's second, and so on. + -- Ordering by artist score alone let the closest similar artist's + -- catalogue fill the whole LIMIT; on the operator's library this arm held + -- exactly one artist for all 17 seeds measured (#3879). + SELECT track_id, sim_score + FROM ( + SELECT t.id AS track_id, asim.score * 0.5 AS sim_score, asim.score AS artist_score, + row_number() OVER (PARTITION BY asim.artist_b_id + ORDER BY md5(t.id::text || $12::text)) AS artist_turn + FROM artist_similarity asim + JOIN tracks t ON t.artist_id = asim.artist_b_id AND t.missing_since IS NULL + JOIN seed_artist sa ON asim.artist_a_id = sa.artist_id + WHERE asim.source = 'listenbrainz' + AND t.id NOT IN (SELECT id FROM excluded_ids) + ) ranked + ORDER BY artist_turn, artist_score DESC, md5(track_id::text || $12::text) LIMIT $6 ), tag_overlap AS ( @@ -882,14 +892,20 @@ coplay_artists AS ( -- the coplay worker). Mirrors similar_artists but from local co-occurrence -- instead of ListenBrainz; empty on single-user servers. Same 0.5 damp as -- similar_artists since it's artist-level. - SELECT t.id AS track_id, asim.score * 0.5 AS sim_score - FROM artist_similarity asim - JOIN tracks t ON t.artist_id = asim.artist_b_id - JOIN seed_artist sa ON asim.artist_a_id = sa.artist_id - WHERE asim.source = 'user_cooccurrence' - AND t.id NOT IN (SELECT id FROM excluded_ids) - AND t.id <> $2 - ORDER BY asim.score DESC, md5(t.id::text || $12::text) + -- Round-robin across artists for the same reason as similar_artists. + SELECT track_id, sim_score + FROM ( + SELECT t.id AS track_id, asim.score * 0.5 AS sim_score, asim.score AS artist_score, + row_number() OVER (PARTITION BY asim.artist_b_id + ORDER BY md5(t.id::text || $12::text)) AS artist_turn + FROM artist_similarity asim + JOIN tracks t ON t.artist_id = asim.artist_b_id AND t.missing_since IS NULL + JOIN seed_artist sa ON asim.artist_a_id = sa.artist_id + WHERE asim.source = 'user_cooccurrence' + AND t.id NOT IN (SELECT id FROM excluded_ids) + AND t.id <> $2 + ) ranked + ORDER BY artist_turn, artist_score DESC, md5(track_id::text || $12::text) LIMIT $11 ), random_fill AS ( diff --git a/internal/db/queries/recommendation.sql b/internal/db/queries/recommendation.sql index 7d9bfa87..cb164e5f 100644 --- a/internal/db/queries/recommendation.sql +++ b/internal/db/queries/recommendation.sql @@ -96,13 +96,23 @@ lb_similar AS ( LIMIT $5 ), similar_artists AS ( - SELECT t.id AS track_id, asim.score * 0.5 AS sim_score - FROM artist_similarity asim - JOIN tracks t ON t.artist_id = asim.artist_b_id - JOIN seed_artist sa ON asim.artist_a_id = sa.artist_id - WHERE asim.source = 'listenbrainz' - AND t.id NOT IN (SELECT id FROM excluded_ids) - ORDER BY asim.score DESC, md5(t.id::text || $12::text) + -- Round-robin across the similar artists (#5297): each artist's first + -- track, best-scoring artist first, then each one's second, and so on. + -- Ordering by artist score alone let the closest similar artist's + -- catalogue fill the whole LIMIT; on the operator's library this arm held + -- exactly one artist for all 17 seeds measured (#3879). + SELECT track_id, sim_score + FROM ( + SELECT t.id AS track_id, asim.score * 0.5 AS sim_score, asim.score AS artist_score, + row_number() OVER (PARTITION BY asim.artist_b_id + ORDER BY md5(t.id::text || $12::text)) AS artist_turn + FROM artist_similarity asim + JOIN tracks t ON t.artist_id = asim.artist_b_id AND t.missing_since IS NULL + JOIN seed_artist sa ON asim.artist_a_id = sa.artist_id + WHERE asim.source = 'listenbrainz' + AND t.id NOT IN (SELECT id FROM excluded_ids) + ) ranked + ORDER BY artist_turn, artist_score DESC, md5(track_id::text || $12::text) LIMIT $6 ), tag_overlap AS ( @@ -150,14 +160,20 @@ coplay_artists AS ( -- the coplay worker). Mirrors similar_artists but from local co-occurrence -- instead of ListenBrainz; empty on single-user servers. Same 0.5 damp as -- similar_artists since it's artist-level. - SELECT t.id AS track_id, asim.score * 0.5 AS sim_score - FROM artist_similarity asim - JOIN tracks t ON t.artist_id = asim.artist_b_id - JOIN seed_artist sa ON asim.artist_a_id = sa.artist_id - WHERE asim.source = 'user_cooccurrence' - AND t.id NOT IN (SELECT id FROM excluded_ids) - AND t.id <> $2 - ORDER BY asim.score DESC, md5(t.id::text || $12::text) + -- Round-robin across artists for the same reason as similar_artists. + SELECT track_id, sim_score + FROM ( + SELECT t.id AS track_id, asim.score * 0.5 AS sim_score, asim.score AS artist_score, + row_number() OVER (PARTITION BY asim.artist_b_id + ORDER BY md5(t.id::text || $12::text)) AS artist_turn + FROM artist_similarity asim + JOIN tracks t ON t.artist_id = asim.artist_b_id AND t.missing_since IS NULL + JOIN seed_artist sa ON asim.artist_a_id = sa.artist_id + WHERE asim.source = 'user_cooccurrence' + AND t.id NOT IN (SELECT id FROM excluded_ids) + AND t.id <> $2 + ) ranked + ORDER BY artist_turn, artist_score DESC, md5(track_id::text || $12::text) LIMIT $11 ), random_fill AS ( diff --git a/internal/recommendation/candidates_v2_test.go b/internal/recommendation/candidates_v2_test.go index af654157..29dfc754 100644 --- a/internal/recommendation/candidates_v2_test.go +++ b/internal/recommendation/candidates_v2_test.go @@ -429,3 +429,69 @@ func TestLoadCandidatesFromSimilarity_DifferentSeedsCanDrawDifferently(t *testin "varying with the seed at all", len(seen)) } } + +// #5297: an artist-level arm takes a track from each related artist before a +// second from any. Ordered by artist score alone, the closest artist's +// catalogue filled the whole LIMIT; on the operator's library the +// similar_artists arm held one artist for every seed measured (#3879). +func TestLoadCandidatesFromSimilarity_ArtistArmsRoundRobin(t *testing.T) { + for _, source := range []string{"listenbrainz", "user_cooccurrence"} { + t.Run(source, func(t *testing.T) { + f := newFixture(t, 1) + ctx := context.Background() + seed := f.tracks[0] + artistOf := map[[16]byte]string{} + for i, score := range []float64{0.9, 0.8, 0.7} { + name := fmt.Sprintf("Related %d", i) + ar, err := f.q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: name, SortName: name}) + if err != nil { + t.Fatal(err) + } + al, err := f.q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: name, SortTitle: name, ArtistID: ar.ID}) + if err != nil { + t.Fatal(err) + } + // More tracks per artist than the arm's limit, so the closest + // artist alone could fill it. + for j := 0; j < 5; j++ { + tr, err := f.q.UpsertTrack(ctx, dbq.UpsertTrackParams{ + Title: fmt.Sprintf("%s #%d", name, j), AlbumID: al.ID, ArtistID: ar.ID, + FilePath: fmt.Sprintf("/tmp/related-%d-%d.flac", i, j), DurationMs: 180_000, + }) + if err != nil { + t.Fatal(err) + } + artistOf[tr.ID.Bytes] = name + } + if _, err := f.pool.Exec(ctx, + `INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source) VALUES ($1, $2, $3, $4)`, + seed.ArtistID, ar.ID, score, source); err != nil { + t.Fatal(err) + } + } + + limits := CandidateSourceLimits{} + if source == "listenbrainz" { + limits.SimilarArtist = 3 + } else { + limits.UserCoplay = 3 + } + got, err := LoadCandidatesFromSimilarity( + ctx, f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, limits, "test-seed", + ) + if err != nil { + t.Fatalf("load: %v", err) + } + artists := map[string]int{} + for _, c := range got { + if name, ok := artistOf[c.Track.ID.Bytes]; ok { + artists[name]++ + } + } + if len(got) != 3 || len(artists) != 3 { + t.Errorf("arm of 3 drew %d candidates from %d related artists (%v), want one from each of 3", + len(got), len(artists), artists) + } + }) + } +}