// tagoverlap.go — the taste-tag term for the Discover request surface // (#2377, milestone #268 slice 6). // // Slices 1-2 made the deck stop repeating; this is what makes it *relevant*. // Before this, a candidate's only claim on a slot was "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 (cached by slice 5) 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 (#2376), and a cold-start user has no taste tags at all. Both must // leave the candidate's similarity score untouched rather than sink it — // rule #131, tiered degradation, never vanish-or-nothing. 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 operator's 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 is the operator turning the feature off. Skip the work // AND the re-sort so the ordering is bit-for-bit the pre-slice-6 result. 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 (#2373) exists to prevent. return pool[i].MBID < pool[j].MBID }) return pool }