Files
minstrel/internal/recommendation/tagoverlap.go
T
bvandeusenandClaude Opus 5.5 fcf5b7e5a8 docs: wanted, dbtest, recommendation and playlists comments say what the code does (#6277)
The same rewrite as d191a282, for four packages whose comments carried the
most history: tuning stories, quotes from the operator, single-install
measurements in library_scale.go, and 'used to' accounts of bugs. The
constraint each story protected stays as a present-tense reason (why tier 3
is reduced not removed, why the randomised arms take an order seed, why the
seed artist is eligible in Songs-like). Comments naming system_cron.go and
config.RecommendationConfig, neither of which holds what they claimed, now
name the scheduler and internal/recsettings.

Checked: non-comment tokens identical to HEAD; gofmt clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:19:15 -04:00

153 lines
5.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// tagoverlap.go — the taste-tag term for the Discover request surface.
//
// Similarity alone says "some artist you play is similar to it", a
// graph-adjacency fact that says nothing about whether the music sounds like
// anything you actually like. Here the candidate's own folksonomy tags (from
// the candidate tag cache) are compared against the user's taste-profile
// tags, so the surface can rank on "matches the sound you like" and say WHY.
//
// Pure by design — no DB, no clock — so the scoring rules are unit-testable in
// the fast lane rather than behind the integration gate.
package recommendation
import "sort"
// maxMatchedTags caps the "matches: …" explanation. Three is what the existing
// seed attribution shows, and a longer list stops being a reason and becomes a
// tag dump.
const maxMatchedTags = 3
// TagWeights is a tag → weight map. Both sides of the comparison use it:
// candidate tags (normalized [0,1] by the enrichment providers) and the user's
// taste-profile tags (accumulated, unbounded — normalized here).
type TagWeights map[string]float64
// tagOverlap scores how much of a candidate's tag identity the user actually
// likes, in [0,1], and returns the matched tags ordered by contribution.
//
// The measure is: of this candidate's total tag mass, what share sits on tags
// the user likes — each weighted by how strongly they like it?
//
// overlap = Σ(shared) candWeight × normalizedTasteWeight ÷ Σ(all) candWeight
//
// Normalizing the taste side by the user's STRONGEST tag is what makes this
// comparable across users: taste weights accumulate with listening, so a
// heavy listener's raw numbers dwarf a new user's while meaning the same
// thing — "this is my favourite tag". Dividing by the candidate's own total
// mass makes it comparable across candidates, so a densely-tagged artist
// can't out-score a sparsely-tagged one just by having more tags.
//
// Returns (0, nil) when either side is empty. That is the load-bearing
// degradation path: tag coverage for out-of-library candidates is permanently
// partial, and a cold-start user has no taste tags at all. Both must leave
// the candidate's similarity score untouched rather than sink it.
func tagOverlap(candidate, taste TagWeights) (float64, []string) {
if len(candidate) == 0 || len(taste) == 0 {
return 0, nil
}
maxTaste := 0.0
for _, w := range taste {
if w > maxTaste {
maxTaste = w
}
}
// Every taste weight <= 0 carries no preference to match against. Guarding
// here also avoids dividing by zero below.
if maxTaste <= 0 {
return 0, nil
}
totalMass := 0.0
for _, w := range candidate {
// Negative or zero candidate weights would let a tag subtract from the
// denominator and inflate the ratio past 1.
if w > 0 {
totalMass += w
}
}
if totalMass <= 0 {
return 0, nil
}
type contribution struct {
tag string
score float64
}
var matched []contribution
sum := 0.0
for tag, candWeight := range candidate {
if candWeight <= 0 {
continue
}
tasteWeight, ok := taste[tag]
if !ok || tasteWeight <= 0 {
continue
}
c := candWeight * (tasteWeight / maxTaste)
sum += c
matched = append(matched, contribution{tag: tag, score: c})
}
if len(matched) == 0 {
return 0, nil
}
// Strongest contribution first; tag name breaks ties so the explanation is
// deterministic for a given input rather than map-iteration order.
sort.Slice(matched, func(i, j int) bool {
if matched[i].score != matched[j].score {
return matched[i].score > matched[j].score
}
return matched[i].tag < matched[j].tag
})
names := make([]string, 0, min(len(matched), maxMatchedTags))
for i := 0; i < len(matched) && i < maxMatchedTags; i++ {
names = append(names, matched[i].tag)
}
return sum / totalMass, names
}
// applyTagOverlap re-scores and re-orders a candidate pool by taste-tag
// overlap, stamping the matched tags onto each suggestion for the UI.
//
// The blend is MULTIPLICATIVE — score × (1 + weight × overlap) — not additive,
// and the difference is the whole safety argument:
//
// - A candidate with no tags has overlap 0, so its score is EXACTLY
// unchanged. Partial tag coverage costs a candidate nothing.
// - Nothing can leapfrog on tags alone. An additive term with a large
// weight would let a near-zero-similarity artist outrank a strong match
// just for sharing a popular tag, which reads as noise to the user.
// - weight 0 disables the feature completely and restores pure similarity
// order, so the knob has a real off position.
//
// Callers must pass the pool in similarity order; it is returned in blended
// order. Mutates the elements in place (they're the caller's own slice built
// per request), and re-sorts, because selectSuggestions downstream relies on
// score order for its head/tail split.
func applyTagOverlap(
pool []ArtistSuggestion, candidateTags map[string]TagWeights, taste TagWeights, weight float64,
) []ArtistSuggestion {
// A zero weight turns the feature off. Skip the work AND the re-sort so
// the ordering is bit-for-bit pure similarity order.
if weight == 0 || len(taste) == 0 {
return pool
}
for i := range pool {
overlap, matched := tagOverlap(candidateTags[pool[i].MBID], taste)
pool[i].MatchedTags = matched
pool[i].TagOverlap = overlap
pool[i].Score *= 1 + weight*overlap
}
sort.SliceStable(pool, func(i, j int) bool {
if pool[i].Score != pool[j].Score {
return pool[i].Score > pool[j].Score
}
// Stable tiebreak by MBID. Without it, two candidates on equal scores
// could swap between requests within the same day, which the daily
// rotation exists to prevent.
return pool[i].MBID < pool[j].MBID
})
return pool
}