From e8fdf8dc540ea1ca8486545859d87231b752bc22 Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Mon, 30 Mar 2026 03:23:45 -0400 Subject: [PATCH] fix: library bonus as post-normalization additive, not pop contamination The +10M library bonus was added directly to the popularity map, which made it the maxPop normalization denominator. With maxPop=10M, every non-library artist's log-normalized popularity collapsed to near-zero, making their blended score purely 40% of MB relevance. All non-indexed artists scored ~35 and ranked by MB noise. New approach: - Removed +10M from both GetPopularityBatch and boostWithPopularity - GetPopularityBatch now returns PopularityBatchResult with separate Popularity and InLibrary maps - rerankArtists takes a libraryMBIDs set and applies a fixed +25 score bonus AFTER blended scoring and normalization - maxPop reflects real popularity only, so log normalization works correctly across all artists Shannon Wright (766K listens) now properly outranks Shannon Kennedy (95 listens) because the popularity scale isn't contaminated. --- backend/explore/explore.go | 60 ++++++++++++++++++++++------------ backend/explore/searchindex.go | 27 ++++++++++----- 2 files changed, 58 insertions(+), 29 deletions(-) diff --git a/backend/explore/explore.go b/backend/explore/explore.go index 8533ff4..cab3fe6 100644 --- a/backend/explore/explore.go +++ b/backend/explore/explore.go @@ -961,6 +961,11 @@ const ( // popularity. The threshold slides between this and // minBlendedScore based on listen count. minScoreZeroPop = 60 + + // libraryScoreBonus is added to library artists' blended scores + // after normalization. Applied post-blending so it doesn't + // pollute the maxPop denominator. + libraryScoreBonus = 25 ) // tierBonus maps artist name-match tiers to percentage score multipliers. @@ -1036,26 +1041,26 @@ func (e *Service) boostWithIndexPopularity(result *MBSearchResult) { } // Single batch query for all popularity + in_library data. - popMap := e.index.GetPopularityBatch(allMBIDs) - if popMap == nil { + batch := e.index.GetPopularityBatch(allMBIDs) + if batch == nil { return } // Build per-entity maps from the batch result. artistPop := make(map[string]int, len(result.Artists)) for i, a := range result.Artists { - if pop, ok := popMap[a.MBID]; ok { + if pop, ok := batch.Popularity[a.MBID]; ok { artistPop[a.MBID] = pop result.Artists[i].HasPopularity = true result.Artists[i].Popularity = pop } } - rerankArtists(result.Artists, artistPop) + rerankArtists(result.Artists, artistPop, batch.InLibrary) rgPop := make(map[string]int, len(result.ReleaseGroups)) for _, rg := range result.ReleaseGroups { - if pop, ok := popMap[rg.MBID]; ok { + if pop, ok := batch.Popularity[rg.MBID]; ok { rgPop[rg.MBID] = pop } } @@ -1064,7 +1069,7 @@ func (e *Service) boostWithIndexPopularity(result *MBSearchResult) { recPop := make(map[string]int, len(result.Recordings)) for _, r := range result.Recordings { - if pop, ok := popMap[r.MBID]; ok { + if pop, ok := batch.Popularity[r.MBID]; ok { recPop[r.MBID] = pop } } @@ -1144,14 +1149,15 @@ func (e *Service) boostWithPopularity(result *MBSearchResult) { wg.Wait() - // Add library bonus to artist popularity — same bonus as the - // fast path (boostWithIndexPopularity via GetPopularityBatch). - if artistPop != nil { - libraryMBIDs := e.libMBID.CheckMBIDs(artistMBIDs) + // Build library MBID set for the library score bonus. + libMBIDs := make(map[string]bool) - for mbid, entityType := range libraryMBIDs { + if artistPop != nil { + libraryCheck := e.libMBID.CheckMBIDs(artistMBIDs) + + for mbid, entityType := range libraryCheck { if entityType == "artist" { - artistPop[mbid] += 10_000_000 //nolint:mnd + libMBIDs[mbid] = true } } } @@ -1167,7 +1173,7 @@ func (e *Service) boostWithPopularity(result *MBSearchResult) { } // Rerank each entity type. - rerankArtists(result.Artists, artistPop) + rerankArtists(result.Artists, artistPop, libMBIDs) rerankRecordings(result.Recordings, recordingPop) rerankReleaseGroups(result.ReleaseGroups, rgPop) } @@ -1323,7 +1329,7 @@ func rgMatchTier(query, title, artistCredit string) int { // rerankArtists sorts artists by blended score and updates their // Score field to the new value (0–100 scale). -func rerankArtists(artists []MBArtist, pop map[string]int) { +func rerankArtists(artists []MBArtist, pop map[string]int, libraryMBIDs map[string]bool) { if len(artists) == 0 { return } @@ -1334,16 +1340,30 @@ func rerankArtists(artists []MBArtist, pop map[string]int) { si := blendedScore(float64(artists[i].Score)/100.0, pop[artists[i].MBID], maxPop) sj := blendedScore(float64(artists[j].Score)/100.0, pop[artists[j].MBID], maxPop) + // Library boost as tiebreaker — library artists win ties. + if si == sj { + iLib := libraryMBIDs[artists[i].MBID] + jLib := libraryMBIDs[artists[j].MBID] + + if iLib != jLib { + return iLib + } + } + return si > sj }) - // Update Score field so the frontend's top-results section can - // use it directly. - maxPop2 := maxListenCount(pop) - + // Update Score field. Library artists get a post-normalization + // bonus that doesn't pollute the maxPop denominator. for i := range artists { - s := blendedScore(float64(artists[i].Score)/100.0, pop[artists[i].MBID], maxPop2) - artists[i].Score = int(s * 100) + s := blendedScore(float64(artists[i].Score)/100.0, pop[artists[i].MBID], maxPop) + score := int(s * 100) + + if libraryMBIDs[artists[i].MBID] { + score += libraryScoreBonus + } + + artists[i].Score = score } } diff --git a/backend/explore/searchindex.go b/backend/explore/searchindex.go index c8e7008..89e03c3 100644 --- a/backend/explore/searchindex.go +++ b/backend/explore/searchindex.go @@ -316,9 +316,15 @@ func (si *SearchIndex) GetPopularity(mbid string) int { return 0 } -// GetPopularityBatch returns popularity (listen count) for multiple -// MBIDs in a single query. Returns a map of MBID → popularity. -func (si *SearchIndex) GetPopularityBatch(mbids []string) map[string]int { +// PopularityBatchResult contains popularity and library status. +type PopularityBatchResult struct { + Popularity map[string]int + InLibrary map[string]bool +} + +// GetPopularityBatch returns popularity (listen count) and library +// status for multiple MBIDs in a single query. +func (si *SearchIndex) GetPopularityBatch(mbids []string) *PopularityBatchResult { if len(mbids) == 0 { return nil } @@ -341,7 +347,10 @@ func (si *SearchIndex) GetPopularityBatch(mbids []string) map[string]int { defer func() { _ = rows.Close() }() - result := make(map[string]int, len(mbids)) + result := &PopularityBatchResult{ + Popularity: make(map[string]int, len(mbids)), + InLibrary: make(map[string]bool), + } for rows.Next() { var mbid string @@ -349,13 +358,13 @@ func (si *SearchIndex) GetPopularityBatch(mbids []string) map[string]int { var inLib int if err := rows.Scan(&mbid, &pop, &inLib); err == nil { - existing, ok := result[mbid] + existing, ok := result.Popularity[mbid] if !ok || pop > existing { - if inLib == 1 { - pop += 10_000_000 //nolint:mnd // library bonus - } + result.Popularity[mbid] = pop + } - result[mbid] = pop + if inLib == 1 { + result.InLibrary[mbid] = true } } }