diff --git a/internal/db/dbq/recommendation.sql.go b/internal/db/dbq/recommendation.sql.go index c054f198..9c88b1c5 100644 --- a/internal/db/dbq/recommendation.sql.go +++ b/internal/db/dbq/recommendation.sql.go @@ -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, diff --git a/internal/db/dbq/similarity.sql.go b/internal/db/dbq/similarity.sql.go index 480c64cd..e97dd274 100644 --- a/internal/db/dbq/similarity.sql.go +++ b/internal/db/dbq/similarity.sql.go @@ -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. diff --git a/internal/db/migrations/0096_similarity_relative_scores.down.sql b/internal/db/migrations/0096_similarity_relative_scores.down.sql new file mode 100644 index 00000000..7c4e468f --- /dev/null +++ b/internal/db/migrations/0096_similarity_relative_scores.down.sql @@ -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; diff --git a/internal/db/migrations/0096_similarity_relative_scores.up.sql b/internal/db/migrations/0096_similarity_relative_scores.up.sql new file mode 100644 index 00000000..5a9a912b --- /dev/null +++ b/internal/db/migrations/0096_similarity_relative_scores.up.sql @@ -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; diff --git a/internal/db/queries/recommendation.sql b/internal/db/queries/recommendation.sql index 3fd2a34e..18a2089b 100644 --- a/internal/db/queries/recommendation.sql +++ b/internal/db/queries/recommendation.sql @@ -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) diff --git a/internal/db/queries/similarity.sql b/internal/db/queries/similarity.sql index 70949a35..3027fe32 100644 --- a/internal/db/queries/similarity.sql +++ b/internal/db/queries/similarity.sql @@ -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 diff --git a/internal/recommendation/candidates.go b/internal/recommendation/candidates.go index ecd1d7e2..30798ea0 100644 --- a/internal/recommendation/candidates.go +++ b/internal/recommendation/candidates.go @@ -127,8 +127,9 @@ func DefaultCandidateSourceLimits() CandidateSourceLimits { // tier 3 taste_overlap, random_fill // seed-independent. The floor, and nothing more. // -// The tiering is enforced by SCORE rather than by a fallback ladder: tier-3 -// arms carry sim_score 0, so under SongsLike weights (SimilarityWeight 4.0, +// The tiering is enforced by SCORE rather than by a fallback ladder: each tier +// 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 // 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". @@ -159,19 +160,19 @@ func DefaultCandidateSourceLimits() CandidateSourceLimits { // 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 -// 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 -// raised SimilarityWeight amplifies it — if real ListenBrainz scores commonly -// land below 0.6 it would outrank genuine matches. Halved pending the -// fill-rate measurement in #3879; the honest fix is to stop it claiming a -// similarity score it never computed. +// raised SimilarityWeight amplifies it. The tier bands keep it below every +// ListenBrainz match (#6177); halved pending the fill-rate measurement in +// #3879, and the honest fix is still to stop it claiming a similarity score +// it never computed. func SongsLikeCandidateSourceLimits() CandidateSourceLimits { return CandidateSourceLimits{ LBSimilar: 60, // tier 1 — doubled; the only arm that measures the seed SimilarArtist: 40, // tier 2 — raised; growing is always safe TagOverlap: 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 RandomFill: 10, // tier 3 floor — cut hard, never to zero } diff --git a/internal/recommendation/candidates_v2_test.go b/internal/recommendation/candidates_v2_test.go index 29dfc754..186977e7 100644 --- a/internal/recommendation/candidates_v2_test.go +++ b/internal/recommendation/candidates_v2_test.go @@ -69,8 +69,9 @@ func TestLoadCandidatesFromSimilarity_LBSimilarSourceContributes(t *testing.T) { if found == nil { t.Fatal("LB-similar target missing from candidates") } - if found.Inputs.SimilarityScore < 0.84 || found.Inputs.SimilarityScore > 0.86 { - t.Errorf("LB-similar SimilarityScore = %v, want ~0.85", found.Inputs.SimilarityScore) + // Tier 1's band (#6177): 0.5 + 0.5 × 0.85. + 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 { if c.Track.ID == target.ID { - // Seed has 2 tags; target shares 1 → jaccard 1/2 = 0.5. - if c.Inputs.SimilarityScore < 0.49 || c.Inputs.SimilarityScore > 0.51 { - t.Errorf("tag-overlap SimilarityScore = %v, want ~0.5", c.Inputs.SimilarityScore) + // Seed has 2 tags; target shares 1 → 1/2, in tier 2's band → 0.25. + if c.Inputs.SimilarityScore < 0.24 || c.Inputs.SimilarityScore > 0.26 { + t.Errorf("tag-overlap SimilarityScore = %v, want ~0.25 (0.5 × 1/2)", c.Inputs.SimilarityScore) } return } @@ -144,10 +145,10 @@ func TestLoadCandidatesFromSimilarity_LikesOverlapContributes(t *testing.T) { } for _, c := range got { if c.Track.ID == liked.ID { - // Both tracks tagged "Rock" → jaccard 1/1 = 1.0 from tag-overlap. - // likes-overlap = 0.6. Max wins = 1.0. - if c.Inputs.SimilarityScore < 0.59 { - t.Errorf("likes-overlap candidate SimilarityScore = %v, want ≥ 0.6", c.Inputs.SimilarityScore) + // Both tracks tagged "Rock" → 0.5 × 1/1 = 0.5 from tag-overlap. + // likes-overlap = 0.3. Max wins = 0.5. + if c.Inputs.SimilarityScore < 0.29 { + t.Errorf("likes-overlap candidate SimilarityScore = %v, want ≥ 0.3", c.Inputs.SimilarityScore) } return } @@ -243,8 +244,8 @@ func TestLoadCandidatesFromSimilarity_DedupTakesMaxScore(t *testing.T) { seed := f.tracks[0] target := f.tracks[1] helperSetTrackGenre(t, f, seed.ID, "Rock") - helperSetTrackGenre(t, f, target.ID, "Rock") // jaccard 1/1 = 1.0 from tag-overlap - helperLBSimilarity(t, f, seed.ID, target.ID, 0.5) // weaker LB signal + helperSetTrackGenre(t, f, target.ID, "Rock") // 0.5 × 1/1 = 0.5 from tag-overlap + helperLBSimilarity(t, f, seed.ID, target.ID, 0.1) // a weak LB edge → 0.5 + 0.05 got, err := LoadCandidatesFromSimilarity( 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 { if c.Track.ID == target.ID { count++ - // tag-overlap (1.0) wins over LB (0.5) per max() — expect ≥ 0.99 - if c.Inputs.SimilarityScore < 0.99 { - t.Errorf("dedup max SimilarityScore = %v, want ≥ 0.99 (tag-overlap should win)", c.Inputs.SimilarityScore) + // max() keeps LB's 0.55. Even a weak ListenBrainz edge is tier 1 and + // outranks a full tag match (#6177). + 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) } } } diff --git a/internal/recsettings/service.go b/internal/recsettings/service.go index 354cddb8..7841ddb7 100644 --- a/internal/recsettings/service.go +++ b/internal/recsettings/service.go @@ -136,7 +136,7 @@ func ShippedDailyMixWeights() recommendation.ScoringWeights { // 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 // 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. func ShippedSongsLikeWeights() recommendation.ScoringWeights { return recommendation.ScoringWeights{ diff --git a/internal/similarity/worker.go b/internal/similarity/worker.go index 5123f2e8..dab420f1 100644 --- a/internal/similarity/worker.go +++ b/internal/similarity/worker.go @@ -199,6 +199,10 @@ func (w *Worker) upsertArtistSimilar(ctx context.Context, q *dbq.Queries, artist return } 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)) for _, r := range results { @@ -230,7 +234,7 @@ func (w *Worker) upsertArtistSimilar(ctx context.Context, q *dbq.Queries, artist continue } 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 { w.logger.Warn("similarity: UpsertArtistSimilarity", "err", uerr) continue @@ -257,7 +261,7 @@ func (w *Worker) upsertArtistSimilar(ctx context.Context, q *dbq.Queries, artist SeedArtistID: artistAID, CandidateMbid: r.MBID, CandidateName: r.Name, - Score: r.Score, + Score: relativeScore(r.Score, best), Source: "listenbrainz", }); uerr != nil { w.logger.Warn("similarity: UpsertArtistSimilarityUnmatched", "err", uerr) @@ -266,3 +270,12 @@ func (w *Worker) upsertArtistSimilar(ctx context.Context, q *dbq.Queries, artist 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 +} diff --git a/internal/similarity/worker_integration_test.go b/internal/similarity/worker_integration_test.go index 940bff5c..229e8131 100644 --- a/internal/similarity/worker_integration_test.go +++ b/internal/similarity/worker_integration_test.go @@ -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 // 50, and every recording of it that is in the library becomes an edge. func TestTickOnce_KeepsEveryInLibraryMatch(t *testing.T) { @@ -279,8 +311,9 @@ func TestTickOnce_RefreshesStaleRow(t *testing.T) { _ = 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'`, trackA.ID, other.ID).Scan(&score, &fetchedAt) - if score != 0.95 { - t.Errorf("score = %v, want 0.95 (refreshed)", score) + // The seed's only answer is its best, so the refreshed edge is 1.0 (#6177). + 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 { t.Errorf("fetched_at not bumped: %v ago", time.Since(fetchedAt)) @@ -419,11 +452,12 @@ func TestUpsertArtistSimilar_PersistsUnmatchedToTable(t *testing.T) { w := newTestWorker(f, "") similars := []listenbrainz.SimilarArtist{ - {MBID: inLibMBID, Name: "InLib Artist", Score: 0.95}, - {MBID: "out-mbid-1", Name: "Outsider One", Score: 0.85}, - {MBID: "out-mbid-2", Name: "Outsider Two", Score: 0.80}, - {MBID: "out-mbid-3", Name: "Outsider Three", Score: 0.70}, - {MBID: "out-mbid-4", Name: "", Score: 0.60}, // empty name — skipped + // LB's scores are counts; rows are stored over the seed's best (#6177). + {MBID: inLibMBID, Name: "InLib Artist", Score: 1000}, + {MBID: "out-mbid-1", Name: "Outsider One", Score: 850}, + {MBID: "out-mbid-2", Name: "Outsider Two", Score: 800}, + {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) @@ -438,6 +472,16 @@ func TestUpsertArtistSimilar_PersistsUnmatchedToTable(t *testing.T) { if matchedCount != 1 { 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. 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) } - _ = inLibArtist } // recordFetch stamps a seed as answered by ListenBrainz at the given SQL time.