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>
153 lines
5.6 KiB
Go
153 lines
5.6 KiB
Go
// 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
|
||
}
|