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

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:
2026-10-21 10:56:26 -04:00
co-authored by Claude Opus 5.5
parent 46e1dd6c9c
commit e44025435e
11 changed files with 216 additions and 45 deletions
@@ -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;