feat: popularity-scaled filter threshold replaces hard cutoffs

Instead of a fixed minBlendedScore or binary has/hasn't-popularity
check, the minimum score threshold now slides based on actual listen
count:

    0 listens     → threshold 60  (need strong name match)
    100 listens   → threshold 45
    1K listens    → threshold 38
    10K listens   → threshold 30
    100K listens  → threshold 23
    1M+ listens   → threshold 15  (almost anything passes)

Uses log scaling so the threshold drops quickly for even modest
popularity and flattens toward the floor for well-known artists.

Shannon Hale (0 listens, score 37) → filtered.
Shannon Kennedy (95 listens, score 58) → kept.
Shannon Wright (766K listens, score 103) → trivially passes.

Added Popularity field to MBArtist, populated by both reranking
paths (index fast path and LB API slow path).
This commit is contained in:
2026-03-30 03:12:38 -04:00
parent fc2a50e853
commit 33087974fa
2 changed files with 57 additions and 19 deletions
+56 -19
View File
@@ -826,9 +826,52 @@ func scalePopularity(listens int) int {
// filterAndCap removes low-scoring results, special-purpose
// MusicBrainz artists, and limits each entity slice to maxResults.
// minScoreForArtist returns the minimum score threshold for an
// artist based on their popularity. The threshold slides from
// minScoreZeroPop (60, for zero-listen artists) down to
// minBlendedScore (15, for popular artists).
//
// Uses log scaling: the threshold drops quickly for even modest
// popularity (1K listens → ~35) and flattens toward the floor
// for high popularity (100K+ → ~18).
//
// 0 listens → threshold 60 (need strong name match)
// 100 listens → threshold 50
// 1K listens → threshold 42
// 10K listens → threshold 33
// 100K listens → threshold 24
// 1M+ listens → threshold 15 (almost anything passes)
func minScoreForArtist(a MBArtist) int {
if a.Popularity <= 0 {
return minScoreZeroPop
}
// log10(pop) ranges from ~2 (100 listens) to ~6+ (1M+).
// Scale to 0-1 range using 6.0 as the reference ceiling.
const logCeiling = 6.0 // log10(1,000,000)
logPop := math.Log10(float64(a.Popularity))
ratio := logPop / logCeiling
if ratio > 1.0 {
ratio = 1.0
}
spread := float64(minScoreZeroPop - minBlendedScore)
threshold := minScoreZeroPop - int(ratio*spread)
if threshold < minBlendedScore {
threshold = minBlendedScore
}
return threshold
}
func filterAndCap(result *MBSearchResult) {
// Filter artists: remove SPAs and zero-popularity artists
// that aren't exact name matches.
// Filter artists: remove SPAs and apply popularity-scaled threshold.
// The minimum score to survive scales with popularity — artists
// with zero listens need a very high score (near-exact match),
// while popular artists pass with any reasonable score.
if len(result.Artists) > 0 {
filtered := result.Artists[:0]
@@ -837,14 +880,7 @@ func filterAndCap(result *MBSearchResult) {
continue
}
if a.Score < minBlendedScore {
continue
}
// Keep artists with popularity data. Also keep artists
// without popularity if they have a high enough score
// (likely exact or close name matches).
if !a.HasPopularity && a.Score < minZeroPopScore {
if a.Score < minScoreForArtist(a) {
continue
}
@@ -911,15 +947,14 @@ const (
// maxResults caps each entity slice after filtering.
maxResults = 15
// minBlendedScore is the floor for artists and recordings
// after popularity reranking and tier adjustment (0100 scale).
minBlendedScore = 25
// minBlendedScore is the absolute floor — no result survives
// below this regardless of popularity.
minBlendedScore = 15
// minZeroPopScore is the floor for artists with zero LB
// popularity data. Higher than minBlendedScore so that
// obscure artists without any listening history are filtered
// unless they're a very strong name match (exact or near-exact).
minZeroPopScore = 50
// minScoreZeroPop is the threshold for artists with zero
// popularity. The threshold slides between this and
// minBlendedScore based on listen count.
minScoreZeroPop = 60
)
// tierBonus maps artist name-match tiers to percentage score multipliers.
@@ -1006,6 +1041,7 @@ func (e *Service) boostWithIndexPopularity(result *MBSearchResult) {
if pop, ok := popMap[a.MBID]; ok {
artistPop[a.MBID] = pop
result.Artists[i].HasPopularity = true
result.Artists[i].Popularity = pop
}
}
@@ -1117,8 +1153,9 @@ func (e *Service) boostWithPopularity(result *MBSearchResult) {
// Mark artists that have popularity data.
if artistPop != nil {
for i := range result.Artists {
if _, ok := artistPop[result.Artists[i].MBID]; ok {
if pop, ok := artistPop[result.Artists[i].MBID]; ok {
result.Artists[i].HasPopularity = true
result.Artists[i].Popularity = pop
}
}
}
+1
View File
@@ -27,6 +27,7 @@ type MBArtist struct {
Score int `json:"score"`
OriginalScore int `json:"-"` // MB search relevance, preserved across reranking
HasPopularity bool `json:"-"` // true if LB/index had listen data for this artist
Popularity int `json:"-"` // raw LB listen count (0 if unknown)
}
// MBReleaseGroup is a Wails-friendly projection of a MusicBrainz