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
|
WHERE q.user_id = $1
|
||||||
),
|
),
|
||||||
lb_similar AS (
|
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
|
FROM track_similarity ts
|
||||||
WHERE ts.track_a_id = $2
|
WHERE ts.track_a_id = $2
|
||||||
AND ts.source = 'listenbrainz'
|
AND ts.source = 'listenbrainz'
|
||||||
@@ -864,8 +864,8 @@ similar_artists AS (
|
|||||||
),
|
),
|
||||||
tag_overlap AS (
|
tag_overlap AS (
|
||||||
SELECT t.id AS track_id,
|
SELECT t.id AS track_id,
|
||||||
(count(DISTINCT trim(g))::float8
|
0.5 * (count(DISTINCT trim(g))::float8
|
||||||
/ GREATEST((SELECT count(*) FROM seed_tags), 1)) AS sim_score
|
/ GREATEST((SELECT count(*) FROM seed_tags), 1)) AS sim_score
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
LEFT JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true
|
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)
|
WHERE trim(g_split.g) IN (SELECT tag FROM seed_tags)
|
||||||
@@ -877,7 +877,7 @@ tag_overlap AS (
|
|||||||
LIMIT $7
|
LIMIT $7
|
||||||
),
|
),
|
||||||
likes_overlap AS (
|
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
|
FROM general_likes gl
|
||||||
WHERE gl.user_id = $1
|
WHERE gl.user_id = $1
|
||||||
AND gl.track_id NOT IN (SELECT id FROM excluded_ids)
|
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
|
// the determinism they promise; radio passes a fresh value per request and
|
||||||
// keeps varying, which is what a radio should do.
|
// keeps varying, which is what a radio should do.
|
||||||
// Returns same shape as LoadRadioCandidates plus similarity_score column.
|
// 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) {
|
func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error) {
|
||||||
rows, err := q.db.Query(ctx, loadRadioCandidatesV2,
|
rows, err := q.db.Query(ctx, loadRadioCandidatesV2,
|
||||||
arg.UserID,
|
arg.UserID,
|
||||||
|
|||||||
@@ -260,10 +260,19 @@ func (q *Queries) RecordTrackSimilarityFetch(ctx context.Context, arg RecordTrac
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resolveListenBrainzTrackEdges = `-- name: ResolveListenBrainzTrackEdges :exec
|
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)
|
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
|
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
|
JOIN tracks t ON t.mbid = c.recording_mbid
|
||||||
AND t.missing_since IS NULL
|
AND t.missing_since IS NULL
|
||||||
AND t.id <> c.seed_track_id
|
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
|
// pool would offer the same song twice. No cap per seed: LB's answer is already
|
||||||
// at most 50.
|
// 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.
|
// 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
|
// Edges written before the cache existed, or copied onto a survivor by a fold
|
||||||
// (merge.sql), stay until their seed is fetched.
|
// (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
|
-- the determinism they promise; radio passes a fresh value per request and
|
||||||
-- keeps varying, which is what a radio should do.
|
-- keeps varying, which is what a radio should do.
|
||||||
-- Returns same shape as LoadRadioCandidates plus similarity_score column.
|
-- 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
|
WITH
|
||||||
seed_artist AS (
|
seed_artist AS (
|
||||||
@@ -87,7 +99,7 @@ excluded_ids AS (
|
|||||||
WHERE q.user_id = $1
|
WHERE q.user_id = $1
|
||||||
),
|
),
|
||||||
lb_similar AS (
|
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
|
FROM track_similarity ts
|
||||||
WHERE ts.track_a_id = $2
|
WHERE ts.track_a_id = $2
|
||||||
AND ts.source = 'listenbrainz'
|
AND ts.source = 'listenbrainz'
|
||||||
@@ -117,8 +129,8 @@ similar_artists AS (
|
|||||||
),
|
),
|
||||||
tag_overlap AS (
|
tag_overlap AS (
|
||||||
SELECT t.id AS track_id,
|
SELECT t.id AS track_id,
|
||||||
(count(DISTINCT trim(g))::float8
|
0.5 * (count(DISTINCT trim(g))::float8
|
||||||
/ GREATEST((SELECT count(*) FROM seed_tags), 1)) AS sim_score
|
/ GREATEST((SELECT count(*) FROM seed_tags), 1)) AS sim_score
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
LEFT JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true
|
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)
|
WHERE trim(g_split.g) IN (SELECT tag FROM seed_tags)
|
||||||
@@ -130,7 +142,7 @@ tag_overlap AS (
|
|||||||
LIMIT $7
|
LIMIT $7
|
||||||
),
|
),
|
||||||
likes_overlap AS (
|
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
|
FROM general_likes gl
|
||||||
WHERE gl.user_id = $1
|
WHERE gl.user_id = $1
|
||||||
AND gl.track_id NOT IN (SELECT id FROM excluded_ids)
|
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
|
-- pool would offer the same song twice. No cap per seed: LB's answer is already
|
||||||
-- at most 50.
|
-- 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.
|
-- 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
|
-- Edges written before the cache existed, or copied onto a survivor by a fold
|
||||||
-- (merge.sql), stay until their seed is fetched.
|
-- (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)
|
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
|
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
|
JOIN tracks t ON t.mbid = c.recording_mbid
|
||||||
AND t.missing_since IS NULL
|
AND t.missing_since IS NULL
|
||||||
AND t.id <> c.seed_track_id
|
AND t.id <> c.seed_track_id
|
||||||
|
|||||||
@@ -127,8 +127,9 @@ func DefaultCandidateSourceLimits() CandidateSourceLimits {
|
|||||||
// tier 3 taste_overlap, random_fill
|
// tier 3 taste_overlap, random_fill
|
||||||
// seed-independent. The floor, and nothing more.
|
// seed-independent. The floor, and nothing more.
|
||||||
//
|
//
|
||||||
// The tiering is enforced by SCORE rather than by a fallback ladder: tier-3
|
// The tiering is enforced by SCORE rather than by a fallback ladder: each tier
|
||||||
// arms carry sim_score 0, so under SongsLike weights (SimilarityWeight 4.0,
|
// holds its own sim_score band (recommendation.sql; tier 1 above 0.5, tier 2
|
||||||
|
// at or below it, #6177), and tier-3 arms carry sim_score 0, so under SongsLike weights (SimilarityWeight 4.0,
|
||||||
// everything seed-independent demoted) they rank below any real match and
|
// everything seed-independent demoted) they rank below any real match and
|
||||||
// surface only when tiers 1–2 cannot fill the mix. That is the rule's
|
// surface only when tiers 1–2 cannot fill the mix. That is the rule's
|
||||||
// "fill from tier 1 first, reach down only when a tier cannot fill".
|
// "fill from tier 1 first, reach down only when a tier cannot fill".
|
||||||
@@ -159,19 +160,19 @@ func DefaultCandidateSourceLimits() CandidateSourceLimits {
|
|||||||
// surface only when the closer tiers cannot fill the mix.
|
// surface only when the closer tiers cannot fill the mix.
|
||||||
//
|
//
|
||||||
// likes_overlap is cut hardest of the tier-2 arms for a specific reason: its
|
// likes_overlap is cut hardest of the tier-2 arms for a specific reason: its
|
||||||
// SQL assigns a FLAT 0.6 sim_score (recommendation.sql) rather than measuring
|
// SQL assigns a FLAT 0.3 sim_score (recommendation.sql) rather than measuring
|
||||||
// anything. It is a collaborative signal wearing similarity's clothes, and a
|
// anything. It is a collaborative signal wearing similarity's clothes, and a
|
||||||
// raised SimilarityWeight amplifies it — if real ListenBrainz scores commonly
|
// raised SimilarityWeight amplifies it. The tier bands keep it below every
|
||||||
// land below 0.6 it would outrank genuine matches. Halved pending the
|
// ListenBrainz match (#6177); halved pending the fill-rate measurement in
|
||||||
// fill-rate measurement in #3879; the honest fix is to stop it claiming a
|
// #3879, and the honest fix is still to stop it claiming a similarity score
|
||||||
// similarity score it never computed.
|
// it never computed.
|
||||||
func SongsLikeCandidateSourceLimits() CandidateSourceLimits {
|
func SongsLikeCandidateSourceLimits() CandidateSourceLimits {
|
||||||
return CandidateSourceLimits{
|
return CandidateSourceLimits{
|
||||||
LBSimilar: 60, // tier 1 — doubled; the only arm that measures the seed
|
LBSimilar: 60, // tier 1 — doubled; the only arm that measures the seed
|
||||||
SimilarArtist: 40, // tier 2 — raised; growing is always safe
|
SimilarArtist: 40, // tier 2 — raised; growing is always safe
|
||||||
TagOverlap: 20, // tier 2
|
TagOverlap: 20, // tier 2
|
||||||
UserCoplay: 20, // tier 2
|
UserCoplay: 20, // tier 2
|
||||||
LikesOverlap: 10, // tier 2, halved — flat 0.6 sim_score, see above
|
LikesOverlap: 10, // tier 2, halved — flat 0.3 sim_score, see above
|
||||||
TasteOverlap: 10, // tier 3 floor — halved, not removed
|
TasteOverlap: 10, // tier 3 floor — halved, not removed
|
||||||
RandomFill: 10, // tier 3 floor — cut hard, never to zero
|
RandomFill: 10, // tier 3 floor — cut hard, never to zero
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,8 +69,9 @@ func TestLoadCandidatesFromSimilarity_LBSimilarSourceContributes(t *testing.T) {
|
|||||||
if found == nil {
|
if found == nil {
|
||||||
t.Fatal("LB-similar target missing from candidates")
|
t.Fatal("LB-similar target missing from candidates")
|
||||||
}
|
}
|
||||||
if found.Inputs.SimilarityScore < 0.84 || found.Inputs.SimilarityScore > 0.86 {
|
// Tier 1's band (#6177): 0.5 + 0.5 × 0.85.
|
||||||
t.Errorf("LB-similar SimilarityScore = %v, want ~0.85", found.Inputs.SimilarityScore)
|
if found.Inputs.SimilarityScore < 0.92 || found.Inputs.SimilarityScore > 0.93 {
|
||||||
|
t.Errorf("LB-similar SimilarityScore = %v, want ~0.925 (0.5 + 0.5 × 0.85)", found.Inputs.SimilarityScore)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,9 +118,9 @@ func TestLoadCandidatesFromSimilarity_TagOverlapContributes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, c := range got {
|
for _, c := range got {
|
||||||
if c.Track.ID == target.ID {
|
if c.Track.ID == target.ID {
|
||||||
// Seed has 2 tags; target shares 1 → jaccard 1/2 = 0.5.
|
// Seed has 2 tags; target shares 1 → 1/2, in tier 2's band → 0.25.
|
||||||
if c.Inputs.SimilarityScore < 0.49 || c.Inputs.SimilarityScore > 0.51 {
|
if c.Inputs.SimilarityScore < 0.24 || c.Inputs.SimilarityScore > 0.26 {
|
||||||
t.Errorf("tag-overlap SimilarityScore = %v, want ~0.5", c.Inputs.SimilarityScore)
|
t.Errorf("tag-overlap SimilarityScore = %v, want ~0.25 (0.5 × 1/2)", c.Inputs.SimilarityScore)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -144,10 +145,10 @@ func TestLoadCandidatesFromSimilarity_LikesOverlapContributes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, c := range got {
|
for _, c := range got {
|
||||||
if c.Track.ID == liked.ID {
|
if c.Track.ID == liked.ID {
|
||||||
// Both tracks tagged "Rock" → jaccard 1/1 = 1.0 from tag-overlap.
|
// Both tracks tagged "Rock" → 0.5 × 1/1 = 0.5 from tag-overlap.
|
||||||
// likes-overlap = 0.6. Max wins = 1.0.
|
// likes-overlap = 0.3. Max wins = 0.5.
|
||||||
if c.Inputs.SimilarityScore < 0.59 {
|
if c.Inputs.SimilarityScore < 0.29 {
|
||||||
t.Errorf("likes-overlap candidate SimilarityScore = %v, want ≥ 0.6", c.Inputs.SimilarityScore)
|
t.Errorf("likes-overlap candidate SimilarityScore = %v, want ≥ 0.3", c.Inputs.SimilarityScore)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -243,8 +244,8 @@ func TestLoadCandidatesFromSimilarity_DedupTakesMaxScore(t *testing.T) {
|
|||||||
seed := f.tracks[0]
|
seed := f.tracks[0]
|
||||||
target := f.tracks[1]
|
target := f.tracks[1]
|
||||||
helperSetTrackGenre(t, f, seed.ID, "Rock")
|
helperSetTrackGenre(t, f, seed.ID, "Rock")
|
||||||
helperSetTrackGenre(t, f, target.ID, "Rock") // jaccard 1/1 = 1.0 from tag-overlap
|
helperSetTrackGenre(t, f, target.ID, "Rock") // 0.5 × 1/1 = 0.5 from tag-overlap
|
||||||
helperLBSimilarity(t, f, seed.ID, target.ID, 0.5) // weaker LB signal
|
helperLBSimilarity(t, f, seed.ID, target.ID, 0.1) // a weak LB edge → 0.5 + 0.05
|
||||||
got, err := LoadCandidatesFromSimilarity(
|
got, err := LoadCandidatesFromSimilarity(
|
||||||
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), "test-seed",
|
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), "test-seed",
|
||||||
)
|
)
|
||||||
@@ -255,9 +256,10 @@ func TestLoadCandidatesFromSimilarity_DedupTakesMaxScore(t *testing.T) {
|
|||||||
for _, c := range got {
|
for _, c := range got {
|
||||||
if c.Track.ID == target.ID {
|
if c.Track.ID == target.ID {
|
||||||
count++
|
count++
|
||||||
// tag-overlap (1.0) wins over LB (0.5) per max() — expect ≥ 0.99
|
// max() keeps LB's 0.55. Even a weak ListenBrainz edge is tier 1 and
|
||||||
if c.Inputs.SimilarityScore < 0.99 {
|
// outranks a full tag match (#6177).
|
||||||
t.Errorf("dedup max SimilarityScore = %v, want ≥ 0.99 (tag-overlap should win)", c.Inputs.SimilarityScore)
|
if c.Inputs.SimilarityScore < 0.54 || c.Inputs.SimilarityScore > 0.56 {
|
||||||
|
t.Errorf("dedup max SimilarityScore = %v, want ~0.55 (the LB edge's band should win)", c.Inputs.SimilarityScore)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ func ShippedDailyMixWeights() recommendation.ScoringWeights {
|
|||||||
// admin tuning card and good values get baked back here. They are a
|
// admin tuning card and good values get baked back here. They are a
|
||||||
// defensible starting point rather than a measured optimum: the per-arm fill
|
// defensible starting point rather than a measured optimum: the per-arm fill
|
||||||
// rates and the real sim_score distribution are still unknown (#3879), and
|
// rates and the real sim_score distribution are still unknown (#3879), and
|
||||||
// `likes_overlap` contributes a flat 0.6 that a high SimilarityWeight
|
// `likes_overlap` contributes a flat 0.3 that a high SimilarityWeight
|
||||||
// amplifies. Expect to move these once that lands.
|
// amplifies. Expect to move these once that lands.
|
||||||
func ShippedSongsLikeWeights() recommendation.ScoringWeights {
|
func ShippedSongsLikeWeights() recommendation.ScoringWeights {
|
||||||
return recommendation.ScoringWeights{
|
return recommendation.ScoringWeights{
|
||||||
|
|||||||
@@ -199,6 +199,10 @@ func (w *Worker) upsertArtistSimilar(ctx context.Context, q *dbq.Queries, artist
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
sort.Slice(results, func(i, j int) bool { return results[i].Score > results[j].Score })
|
sort.Slice(results, func(i, j int) bool { return results[i].Score > results[j].Score })
|
||||||
|
// Scores are stored relative to the seed's closest artist, as track edges
|
||||||
|
// are (#6177): LB's raw scores are counts (16 to 10,875 on the operator's
|
||||||
|
// library), and both the candidate pool and the suggestions read 0 to 1.
|
||||||
|
best := results[0].Score
|
||||||
|
|
||||||
mbids := make([]string, 0, len(results))
|
mbids := make([]string, 0, len(results))
|
||||||
for _, r := range results {
|
for _, r := range results {
|
||||||
@@ -230,7 +234,7 @@ func (w *Worker) upsertArtistSimilar(ctx context.Context, q *dbq.Queries, artist
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if uerr := q.UpsertArtistSimilarity(ctx, dbq.UpsertArtistSimilarityParams{
|
if uerr := q.UpsertArtistSimilarity(ctx, dbq.UpsertArtistSimilarityParams{
|
||||||
ArtistAID: artistAID, ArtistBID: localID, Score: r.Score,
|
ArtistAID: artistAID, ArtistBID: localID, Score: relativeScore(r.Score, best),
|
||||||
}); uerr != nil {
|
}); uerr != nil {
|
||||||
w.logger.Warn("similarity: UpsertArtistSimilarity", "err", uerr)
|
w.logger.Warn("similarity: UpsertArtistSimilarity", "err", uerr)
|
||||||
continue
|
continue
|
||||||
@@ -257,7 +261,7 @@ func (w *Worker) upsertArtistSimilar(ctx context.Context, q *dbq.Queries, artist
|
|||||||
SeedArtistID: artistAID,
|
SeedArtistID: artistAID,
|
||||||
CandidateMbid: r.MBID,
|
CandidateMbid: r.MBID,
|
||||||
CandidateName: r.Name,
|
CandidateName: r.Name,
|
||||||
Score: r.Score,
|
Score: relativeScore(r.Score, best),
|
||||||
Source: "listenbrainz",
|
Source: "listenbrainz",
|
||||||
}); uerr != nil {
|
}); uerr != nil {
|
||||||
w.logger.Warn("similarity: UpsertArtistSimilarityUnmatched", "err", uerr)
|
w.logger.Warn("similarity: UpsertArtistSimilarityUnmatched", "err", uerr)
|
||||||
@@ -266,3 +270,12 @@ func (w *Worker) upsertArtistSimilar(ctx context.Context, q *dbq.Queries, artist
|
|||||||
takenUnmatched++
|
takenUnmatched++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// relativeScore is score over the seed's best, in [0, 1]; 0 when the seed has
|
||||||
|
// no positive score to measure against.
|
||||||
|
func relativeScore(score, best float64) float64 {
|
||||||
|
if best <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return score / best
|
||||||
|
}
|
||||||
|
|||||||
@@ -209,6 +209,38 @@ func TestTickOnce_MapsLBResponseToLocalLibrary(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LB's scores are counts, not 0 to 1 (16 to 1445 on the operator's library).
|
||||||
|
// An edge is stored over the seed's best score in the whole answer, in the
|
||||||
|
// library or not, so the scorer reads it as 0 to 1 (#6177).
|
||||||
|
func TestTickOnce_EdgeIsRelativeToTheSeedsBest(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
mbidA := "11111111-1111-1111-1111-111111111111"
|
||||||
|
mbidB := "22222222-2222-2222-2222-222222222222"
|
||||||
|
mbidC := "99999999-9999-9999-9999-999999999999" // NOT in library
|
||||||
|
trackA := seedTrack(t, f, "A", &mbidA)
|
||||||
|
trackB := seedTrack(t, f, "B", &mbidB)
|
||||||
|
markPlayed(t, f, trackA.ID)
|
||||||
|
body := `[
|
||||||
|
{"recording_mbid": "` + mbidC + `", "score": 1445},
|
||||||
|
{"recording_mbid": "` + mbidB + `", "score": 289}
|
||||||
|
]`
|
||||||
|
srv := stubLB(body, `[]`, http.StatusOK)
|
||||||
|
defer srv.Close()
|
||||||
|
w := newTestWorker(f, srv.URL)
|
||||||
|
if err := w.tickOnce(context.Background()); err != nil {
|
||||||
|
t.Fatalf("tickOnce: %v", err)
|
||||||
|
}
|
||||||
|
var score float64
|
||||||
|
if err := f.pool.QueryRow(context.Background(),
|
||||||
|
`SELECT score FROM track_similarity WHERE track_a_id = $1 AND track_b_id = $2 AND source = 'listenbrainz'`,
|
||||||
|
trackA.ID, trackB.ID).Scan(&score); err != nil {
|
||||||
|
t.Fatalf("edge: %v", err)
|
||||||
|
}
|
||||||
|
if score != 0.2 {
|
||||||
|
t.Errorf("score = %v, want 0.2 (289 over the seed's best, 1445)", score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The 20-per-seed cap is gone with the cache (#5296): LB's answer is at most
|
// The 20-per-seed cap is gone with the cache (#5296): LB's answer is at most
|
||||||
// 50, and every recording of it that is in the library becomes an edge.
|
// 50, and every recording of it that is in the library becomes an edge.
|
||||||
func TestTickOnce_KeepsEveryInLibraryMatch(t *testing.T) {
|
func TestTickOnce_KeepsEveryInLibraryMatch(t *testing.T) {
|
||||||
@@ -279,8 +311,9 @@ func TestTickOnce_RefreshesStaleRow(t *testing.T) {
|
|||||||
_ = f.pool.QueryRow(context.Background(),
|
_ = f.pool.QueryRow(context.Background(),
|
||||||
`SELECT score, fetched_at FROM track_similarity WHERE track_a_id = $1 AND track_b_id = $2 AND source = 'listenbrainz'`,
|
`SELECT score, fetched_at FROM track_similarity WHERE track_a_id = $1 AND track_b_id = $2 AND source = 'listenbrainz'`,
|
||||||
trackA.ID, other.ID).Scan(&score, &fetchedAt)
|
trackA.ID, other.ID).Scan(&score, &fetchedAt)
|
||||||
if score != 0.95 {
|
// The seed's only answer is its best, so the refreshed edge is 1.0 (#6177).
|
||||||
t.Errorf("score = %v, want 0.95 (refreshed)", score)
|
if score != 1.0 {
|
||||||
|
t.Errorf("score = %v, want 1.0 (refreshed, relative to the seed's best)", score)
|
||||||
}
|
}
|
||||||
if time.Since(fetchedAt) > time.Minute {
|
if time.Since(fetchedAt) > time.Minute {
|
||||||
t.Errorf("fetched_at not bumped: %v ago", time.Since(fetchedAt))
|
t.Errorf("fetched_at not bumped: %v ago", time.Since(fetchedAt))
|
||||||
@@ -419,11 +452,12 @@ func TestUpsertArtistSimilar_PersistsUnmatchedToTable(t *testing.T) {
|
|||||||
w := newTestWorker(f, "")
|
w := newTestWorker(f, "")
|
||||||
|
|
||||||
similars := []listenbrainz.SimilarArtist{
|
similars := []listenbrainz.SimilarArtist{
|
||||||
{MBID: inLibMBID, Name: "InLib Artist", Score: 0.95},
|
// LB's scores are counts; rows are stored over the seed's best (#6177).
|
||||||
{MBID: "out-mbid-1", Name: "Outsider One", Score: 0.85},
|
{MBID: inLibMBID, Name: "InLib Artist", Score: 1000},
|
||||||
{MBID: "out-mbid-2", Name: "Outsider Two", Score: 0.80},
|
{MBID: "out-mbid-1", Name: "Outsider One", Score: 850},
|
||||||
{MBID: "out-mbid-3", Name: "Outsider Three", Score: 0.70},
|
{MBID: "out-mbid-2", Name: "Outsider Two", Score: 800},
|
||||||
{MBID: "out-mbid-4", Name: "", Score: 0.60}, // empty name — skipped
|
{MBID: "out-mbid-3", Name: "Outsider Three", Score: 700},
|
||||||
|
{MBID: "out-mbid-4", Name: "", Score: 600}, // empty name — skipped
|
||||||
}
|
}
|
||||||
w.upsertArtistSimilar(ctx, f.q, f.artist.ID, similars)
|
w.upsertArtistSimilar(ctx, f.q, f.artist.ID, similars)
|
||||||
|
|
||||||
@@ -438,6 +472,16 @@ func TestUpsertArtistSimilar_PersistsUnmatchedToTable(t *testing.T) {
|
|||||||
if matchedCount != 1 {
|
if matchedCount != 1 {
|
||||||
t.Errorf("artist_similarity rows = %d, want 1 (only the in-library match)", matchedCount)
|
t.Errorf("artist_similarity rows = %d, want 1 (only the in-library match)", matchedCount)
|
||||||
}
|
}
|
||||||
|
var matchedScore float64
|
||||||
|
if err := f.pool.QueryRow(ctx,
|
||||||
|
"SELECT score FROM artist_similarity WHERE artist_a_id = $1 AND artist_b_id = $2",
|
||||||
|
f.artist.ID, inLibArtist.ID,
|
||||||
|
).Scan(&matchedScore); err != nil {
|
||||||
|
t.Fatalf("fetch matched score: %v", err)
|
||||||
|
}
|
||||||
|
if matchedScore != 1.0 {
|
||||||
|
t.Errorf("matched score = %v, want 1.0 (the seed's best)", matchedScore)
|
||||||
|
}
|
||||||
|
|
||||||
// Unmatched path: 3 rows (out-mbid-1/2/3); the empty-name row is skipped.
|
// Unmatched path: 3 rows (out-mbid-1/2/3); the empty-name row is skipped.
|
||||||
var unmatchedCount int
|
var unmatchedCount int
|
||||||
@@ -464,7 +508,6 @@ func TestUpsertArtistSimilar_PersistsUnmatchedToTable(t *testing.T) {
|
|||||||
t.Errorf("row = (%q, %v), want (Outsider One, 0.85)", name, score)
|
t.Errorf("row = (%q, %v), want (Outsider One, 0.85)", name, score)
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = inLibArtist
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// recordFetch stamps a seed as answered by ListenBrainz at the given SQL time.
|
// recordFetch stamps a seed as answered by ListenBrainz at the given SQL time.
|
||||||
|
|||||||
Reference in New Issue
Block a user