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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user