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 <noreply@anthropic.com>
This commit is contained in:
@@ -828,13 +828,23 @@ lb_similar AS (
|
|||||||
LIMIT $5
|
LIMIT $5
|
||||||
),
|
),
|
||||||
similar_artists AS (
|
similar_artists AS (
|
||||||
SELECT t.id AS track_id, asim.score * 0.5 AS sim_score
|
-- Round-robin across the similar artists (#5297): each artist's first
|
||||||
FROM artist_similarity asim
|
-- track, best-scoring artist first, then each one's second, and so on.
|
||||||
JOIN tracks t ON t.artist_id = asim.artist_b_id
|
-- Ordering by artist score alone let the closest similar artist's
|
||||||
JOIN seed_artist sa ON asim.artist_a_id = sa.artist_id
|
-- catalogue fill the whole LIMIT; on the operator's library this arm held
|
||||||
WHERE asim.source = 'listenbrainz'
|
-- exactly one artist for all 17 seeds measured (#3879).
|
||||||
AND t.id NOT IN (SELECT id FROM excluded_ids)
|
SELECT track_id, sim_score
|
||||||
ORDER BY asim.score DESC, md5(t.id::text || $12::text)
|
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
|
LIMIT $6
|
||||||
),
|
),
|
||||||
tag_overlap AS (
|
tag_overlap AS (
|
||||||
@@ -882,14 +892,20 @@ coplay_artists AS (
|
|||||||
-- the coplay worker). Mirrors similar_artists but from local co-occurrence
|
-- the coplay worker). Mirrors similar_artists but from local co-occurrence
|
||||||
-- instead of ListenBrainz; empty on single-user servers. Same 0.5 damp as
|
-- instead of ListenBrainz; empty on single-user servers. Same 0.5 damp as
|
||||||
-- similar_artists since it's artist-level.
|
-- similar_artists since it's artist-level.
|
||||||
SELECT t.id AS track_id, asim.score * 0.5 AS sim_score
|
-- Round-robin across artists for the same reason as similar_artists.
|
||||||
FROM artist_similarity asim
|
SELECT track_id, sim_score
|
||||||
JOIN tracks t ON t.artist_id = asim.artist_b_id
|
FROM (
|
||||||
JOIN seed_artist sa ON asim.artist_a_id = sa.artist_id
|
SELECT t.id AS track_id, asim.score * 0.5 AS sim_score, asim.score AS artist_score,
|
||||||
WHERE asim.source = 'user_cooccurrence'
|
row_number() OVER (PARTITION BY asim.artist_b_id
|
||||||
AND t.id NOT IN (SELECT id FROM excluded_ids)
|
ORDER BY md5(t.id::text || $12::text)) AS artist_turn
|
||||||
AND t.id <> $2
|
FROM artist_similarity asim
|
||||||
ORDER BY asim.score DESC, md5(t.id::text || $12::text)
|
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
|
LIMIT $11
|
||||||
),
|
),
|
||||||
random_fill AS (
|
random_fill AS (
|
||||||
|
|||||||
@@ -96,13 +96,23 @@ lb_similar AS (
|
|||||||
LIMIT $5
|
LIMIT $5
|
||||||
),
|
),
|
||||||
similar_artists AS (
|
similar_artists AS (
|
||||||
SELECT t.id AS track_id, asim.score * 0.5 AS sim_score
|
-- Round-robin across the similar artists (#5297): each artist's first
|
||||||
FROM artist_similarity asim
|
-- track, best-scoring artist first, then each one's second, and so on.
|
||||||
JOIN tracks t ON t.artist_id = asim.artist_b_id
|
-- Ordering by artist score alone let the closest similar artist's
|
||||||
JOIN seed_artist sa ON asim.artist_a_id = sa.artist_id
|
-- catalogue fill the whole LIMIT; on the operator's library this arm held
|
||||||
WHERE asim.source = 'listenbrainz'
|
-- exactly one artist for all 17 seeds measured (#3879).
|
||||||
AND t.id NOT IN (SELECT id FROM excluded_ids)
|
SELECT track_id, sim_score
|
||||||
ORDER BY asim.score DESC, md5(t.id::text || $12::text)
|
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
|
LIMIT $6
|
||||||
),
|
),
|
||||||
tag_overlap AS (
|
tag_overlap AS (
|
||||||
@@ -150,14 +160,20 @@ coplay_artists AS (
|
|||||||
-- the coplay worker). Mirrors similar_artists but from local co-occurrence
|
-- the coplay worker). Mirrors similar_artists but from local co-occurrence
|
||||||
-- instead of ListenBrainz; empty on single-user servers. Same 0.5 damp as
|
-- instead of ListenBrainz; empty on single-user servers. Same 0.5 damp as
|
||||||
-- similar_artists since it's artist-level.
|
-- similar_artists since it's artist-level.
|
||||||
SELECT t.id AS track_id, asim.score * 0.5 AS sim_score
|
-- Round-robin across artists for the same reason as similar_artists.
|
||||||
FROM artist_similarity asim
|
SELECT track_id, sim_score
|
||||||
JOIN tracks t ON t.artist_id = asim.artist_b_id
|
FROM (
|
||||||
JOIN seed_artist sa ON asim.artist_a_id = sa.artist_id
|
SELECT t.id AS track_id, asim.score * 0.5 AS sim_score, asim.score AS artist_score,
|
||||||
WHERE asim.source = 'user_cooccurrence'
|
row_number() OVER (PARTITION BY asim.artist_b_id
|
||||||
AND t.id NOT IN (SELECT id FROM excluded_ids)
|
ORDER BY md5(t.id::text || $12::text)) AS artist_turn
|
||||||
AND t.id <> $2
|
FROM artist_similarity asim
|
||||||
ORDER BY asim.score DESC, md5(t.id::text || $12::text)
|
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
|
LIMIT $11
|
||||||
),
|
),
|
||||||
random_fill AS (
|
random_fill AS (
|
||||||
|
|||||||
@@ -429,3 +429,69 @@ func TestLoadCandidatesFromSimilarity_DifferentSeedsCanDrawDifferently(t *testin
|
|||||||
"varying with the seed at all", len(seen))
|
"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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user