fix: ListenBrainz similarity is stored relative to the seed's best, and the candidate arms keep tier order (#6177)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m36s
release / integration (push) Successful in 5m4s
release / android (push) Successful in 5m5s
release / Build signed APK (releases and dev) (push) Successful in 5m18s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 30s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m36s
release / integration (push) Successful in 5m4s
release / android (push) Successful in 5m5s
release / Build signed APK (releases and dev) (push) Successful in 5m18s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 30s
release / Verify release artifacts (tag releases only) (push) Skipped
LB's similarity scores are counts (16 to 1445 for recordings, to 10,875 for artists), and they reached a scorer that reads similarity as 0 to 1. One edge added 64 to 5,800 points under Songs-like, so likes, skips and recency stopped mattering for any candidate with an edge. - ResolveListenBrainzTrackEdges divides each edge by the seed's best score in its whole cached answer. The cache keeps the raw answer. - The worker stores matched and unmatched artist rows over the seed's top artist. - Migration 0096 rescales the rows already stored. - The candidate arms hold sim_score bands so tier 1 stays above tier 2: lb_similar 0.5 + 0.5 x edge, tag_overlap halved, likes_overlap 0.3. Otherwise a seed's weaker LB edges fell below a shared genre tag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -834,7 +834,7 @@ excluded_ids AS (
|
||||
WHERE q.user_id = $1
|
||||
),
|
||||
lb_similar AS (
|
||||
SELECT ts.track_b_id AS track_id, ts.score AS sim_score
|
||||
SELECT ts.track_b_id AS track_id, 0.5 + 0.5 * ts.score AS sim_score
|
||||
FROM track_similarity ts
|
||||
WHERE ts.track_a_id = $2
|
||||
AND ts.source = 'listenbrainz'
|
||||
@@ -864,8 +864,8 @@ similar_artists AS (
|
||||
),
|
||||
tag_overlap AS (
|
||||
SELECT t.id AS track_id,
|
||||
(count(DISTINCT trim(g))::float8
|
||||
/ GREATEST((SELECT count(*) FROM seed_tags), 1)) AS sim_score
|
||||
0.5 * (count(DISTINCT trim(g))::float8
|
||||
/ GREATEST((SELECT count(*) FROM seed_tags), 1)) AS sim_score
|
||||
FROM tracks t
|
||||
LEFT JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true
|
||||
WHERE trim(g_split.g) IN (SELECT tag FROM seed_tags)
|
||||
@@ -877,7 +877,7 @@ tag_overlap AS (
|
||||
LIMIT $7
|
||||
),
|
||||
likes_overlap AS (
|
||||
SELECT gl.track_id, 0.6::float8 AS sim_score
|
||||
SELECT gl.track_id, 0.3::float8 AS sim_score
|
||||
FROM general_likes gl
|
||||
WHERE gl.user_id = $1
|
||||
AND gl.track_id NOT IN (SELECT id FROM excluded_ids)
|
||||
@@ -1025,6 +1025,20 @@ type LoadRadioCandidatesV2Row struct {
|
||||
// the determinism they promise; radio passes a fresh value per request and
|
||||
// keeps varying, which is what a radio should do.
|
||||
// Returns same shape as LoadRadioCandidates plus similarity_score column.
|
||||
//
|
||||
// sim_score BANDS keep the tiers in order (rule 131, #6177). Similarity edges
|
||||
// are stored relative to the seed's best match, 0 to 1, so the arms are placed
|
||||
// in bands rather than compared raw:
|
||||
//
|
||||
// tier 1 lb_similar 0.5 + 0.5 × edge (0.5, 1.0]
|
||||
// tier 2 similar_artists, coplay_artists 0.5 × edge [0, 0.5]
|
||||
// tag_overlap 0.5 × share of the seed's tags
|
||||
// likes_overlap 0.3, flat
|
||||
// tier 3 taste_overlap, random_fill 0
|
||||
//
|
||||
// Without the bands, the seed's weaker LB edges fell below a shared genre tag
|
||||
// and the flat likes score, and the one arm that measures the seed lost to
|
||||
// the ones that guess.
|
||||
func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error) {
|
||||
rows, err := q.db.Query(ctx, loadRadioCandidatesV2,
|
||||
arg.UserID,
|
||||
|
||||
@@ -260,10 +260,19 @@ func (q *Queries) RecordTrackSimilarityFetch(ctx context.Context, arg RecordTrac
|
||||
}
|
||||
|
||||
const resolveListenBrainzTrackEdges = `-- name: ResolveListenBrainzTrackEdges :exec
|
||||
WITH want AS (
|
||||
WITH best AS (
|
||||
SELECT seed_track_id, max(score) AS score
|
||||
FROM listenbrainz_similar_recordings
|
||||
WHERE $1::uuid[] IS NULL
|
||||
OR seed_track_id = ANY($1::uuid[])
|
||||
GROUP BY seed_track_id
|
||||
),
|
||||
want AS (
|
||||
SELECT DISTINCT ON (c.seed_track_id, c.recording_mbid)
|
||||
c.seed_track_id AS track_a_id, t.id AS track_b_id, c.score
|
||||
c.seed_track_id AS track_a_id, t.id AS track_b_id,
|
||||
CASE WHEN b.score > 0 THEN c.score / b.score ELSE 0 END AS score
|
||||
FROM listenbrainz_similar_recordings c
|
||||
JOIN best b ON b.seed_track_id = c.seed_track_id
|
||||
JOIN tracks t ON t.mbid = c.recording_mbid
|
||||
AND t.missing_since IS NULL
|
||||
AND t.id <> c.seed_track_id
|
||||
@@ -299,6 +308,13 @@ WHERE ts.source = 'listenbrainz'
|
||||
// pool would offer the same song twice. No cap per seed: LB's answer is already
|
||||
// at most 50.
|
||||
//
|
||||
// The score is RELATIVE: each edge's LB score over the seed's best score in its
|
||||
// whole cached answer, so 1.0 is the seed's closest recording and the rest fall
|
||||
// below it (#6177). LB's raw scores are counts (16 to 1445 on the operator's
|
||||
// library) and reached the scorer, which reads similarity as 0 to 1, so one
|
||||
// edge outweighed likes, skips and recency together. The cache keeps the raw
|
||||
// answer; only the edge is scaled.
|
||||
//
|
||||
// Only edges of seeds the cache owns are removed, those with a fetch row.
|
||||
// Edges written before the cache existed, or copied onto a survivor by a fold
|
||||
// (merge.sql), stay until their seed is fetched.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- The raw scores are not kept for artists; the worker rewrites every seed with
|
||||
-- relative scores on its next fetch either way. Nothing to undo.
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,51 @@
|
||||
-- #6177: ListenBrainz similarity scores were stored raw. They are counts (16 to
|
||||
-- 1445 for recordings, 16 to 10,875 for artists on the operator's library), and
|
||||
-- every reader treats similarity as 0 to 1. Rescale the stored rows relative to
|
||||
-- each seed's best score, as the worker now writes them.
|
||||
--
|
||||
-- A seed's best is taken over everything stored for it: the cached LB answer
|
||||
-- and its edges for tracks, matched and unmatched rows for artists. A seed
|
||||
-- whose best is already at most 1 is left alone, so a second run changes
|
||||
-- nothing.
|
||||
|
||||
WITH best AS (
|
||||
SELECT seed_id, max(score) AS score
|
||||
FROM (
|
||||
SELECT track_a_id AS seed_id, score FROM track_similarity WHERE source = 'listenbrainz'
|
||||
UNION ALL
|
||||
SELECT seed_track_id, score FROM listenbrainz_similar_recordings
|
||||
) s
|
||||
GROUP BY seed_id
|
||||
)
|
||||
UPDATE track_similarity ts
|
||||
SET score = ts.score / b.score
|
||||
FROM best b
|
||||
WHERE ts.source = 'listenbrainz'
|
||||
AND ts.track_a_id = b.seed_id
|
||||
AND b.score > 1;
|
||||
|
||||
-- One statement, so both updates divide by the best as it stood before either.
|
||||
WITH best AS (
|
||||
SELECT seed_id, max(score) AS score
|
||||
FROM (
|
||||
SELECT artist_a_id AS seed_id, score FROM artist_similarity WHERE source = 'listenbrainz'
|
||||
UNION ALL
|
||||
SELECT seed_artist_id, score FROM artist_similarity_unmatched WHERE source = 'listenbrainz'
|
||||
) s
|
||||
GROUP BY seed_id
|
||||
),
|
||||
matched AS (
|
||||
UPDATE artist_similarity a
|
||||
SET score = a.score / b.score
|
||||
FROM best b
|
||||
WHERE a.source = 'listenbrainz'
|
||||
AND a.artist_a_id = b.seed_id
|
||||
AND b.score > 1
|
||||
RETURNING 1
|
||||
)
|
||||
UPDATE artist_similarity_unmatched u
|
||||
SET score = u.score / b.score
|
||||
FROM best b
|
||||
WHERE u.source = 'listenbrainz'
|
||||
AND u.seed_artist_id = b.seed_id
|
||||
AND b.score > 1;
|
||||
@@ -62,6 +62,18 @@ WHERE t.id <> $2
|
||||
-- the determinism they promise; radio passes a fresh value per request and
|
||||
-- keeps varying, which is what a radio should do.
|
||||
-- Returns same shape as LoadRadioCandidates plus similarity_score column.
|
||||
--
|
||||
-- sim_score BANDS keep the tiers in order (rule 131, #6177). Similarity edges
|
||||
-- are stored relative to the seed's best match, 0 to 1, so the arms are placed
|
||||
-- in bands rather than compared raw:
|
||||
-- tier 1 lb_similar 0.5 + 0.5 × edge (0.5, 1.0]
|
||||
-- tier 2 similar_artists, coplay_artists 0.5 × edge [0, 0.5]
|
||||
-- tag_overlap 0.5 × share of the seed's tags
|
||||
-- likes_overlap 0.3, flat
|
||||
-- tier 3 taste_overlap, random_fill 0
|
||||
-- Without the bands, the seed's weaker LB edges fell below a shared genre tag
|
||||
-- and the flat likes score, and the one arm that measures the seed lost to
|
||||
-- the ones that guess.
|
||||
|
||||
WITH
|
||||
seed_artist AS (
|
||||
@@ -87,7 +99,7 @@ excluded_ids AS (
|
||||
WHERE q.user_id = $1
|
||||
),
|
||||
lb_similar AS (
|
||||
SELECT ts.track_b_id AS track_id, ts.score AS sim_score
|
||||
SELECT ts.track_b_id AS track_id, 0.5 + 0.5 * ts.score AS sim_score
|
||||
FROM track_similarity ts
|
||||
WHERE ts.track_a_id = $2
|
||||
AND ts.source = 'listenbrainz'
|
||||
@@ -117,8 +129,8 @@ similar_artists AS (
|
||||
),
|
||||
tag_overlap AS (
|
||||
SELECT t.id AS track_id,
|
||||
(count(DISTINCT trim(g))::float8
|
||||
/ GREATEST((SELECT count(*) FROM seed_tags), 1)) AS sim_score
|
||||
0.5 * (count(DISTINCT trim(g))::float8
|
||||
/ GREATEST((SELECT count(*) FROM seed_tags), 1)) AS sim_score
|
||||
FROM tracks t
|
||||
LEFT JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true
|
||||
WHERE trim(g_split.g) IN (SELECT tag FROM seed_tags)
|
||||
@@ -130,7 +142,7 @@ tag_overlap AS (
|
||||
LIMIT $7
|
||||
),
|
||||
likes_overlap AS (
|
||||
SELECT gl.track_id, 0.6::float8 AS sim_score
|
||||
SELECT gl.track_id, 0.3::float8 AS sim_score
|
||||
FROM general_likes gl
|
||||
WHERE gl.user_id = $1
|
||||
AND gl.track_id NOT IN (SELECT id FROM excluded_ids)
|
||||
|
||||
@@ -68,13 +68,29 @@ ON CONFLICT (artist_id) DO UPDATE SET fetched_at = now(), returned = EXCLUDED.re
|
||||
-- pool would offer the same song twice. No cap per seed: LB's answer is already
|
||||
-- at most 50.
|
||||
--
|
||||
-- The score is RELATIVE: each edge's LB score over the seed's best score in its
|
||||
-- whole cached answer, so 1.0 is the seed's closest recording and the rest fall
|
||||
-- below it (#6177). LB's raw scores are counts (16 to 1445 on the operator's
|
||||
-- library) and reached the scorer, which reads similarity as 0 to 1, so one
|
||||
-- edge outweighed likes, skips and recency together. The cache keeps the raw
|
||||
-- answer; only the edge is scaled.
|
||||
--
|
||||
-- Only edges of seeds the cache owns are removed, those with a fetch row.
|
||||
-- Edges written before the cache existed, or copied onto a survivor by a fold
|
||||
-- (merge.sql), stay until their seed is fetched.
|
||||
WITH want AS (
|
||||
WITH best AS (
|
||||
SELECT seed_track_id, max(score) AS score
|
||||
FROM listenbrainz_similar_recordings
|
||||
WHERE sqlc.narg(seed_ids)::uuid[] IS NULL
|
||||
OR seed_track_id = ANY(sqlc.narg(seed_ids)::uuid[])
|
||||
GROUP BY seed_track_id
|
||||
),
|
||||
want AS (
|
||||
SELECT DISTINCT ON (c.seed_track_id, c.recording_mbid)
|
||||
c.seed_track_id AS track_a_id, t.id AS track_b_id, c.score
|
||||
c.seed_track_id AS track_a_id, t.id AS track_b_id,
|
||||
CASE WHEN b.score > 0 THEN c.score / b.score ELSE 0 END AS score
|
||||
FROM listenbrainz_similar_recordings c
|
||||
JOIN best b ON b.seed_track_id = c.seed_track_id
|
||||
JOIN tracks t ON t.mbid = c.recording_mbid
|
||||
AND t.missing_since IS NULL
|
||||
AND t.id <> c.seed_track_id
|
||||
|
||||
Reference in New Issue
Block a user