The payoff slice. Until now a candidate's only claim on a slot was "some
artist you play is adjacent to it in a similarity graph" — a fact that says
nothing about whether the music sounds like anything you like. Now the
candidate's own folksonomy tags (cached by slice 5) are compared against
the user's taste-profile tags, so the deck ranks on taste and can say WHY.
The blend is MULTIPLICATIVE — score × (1 + weight × overlap) — and that
choice carries the whole safety argument:
- An untagged candidate has overlap 0, so its score is EXACTLY unchanged.
Tag coverage is permanently partial (#2376); it must cost a candidate
nothing, not sink it (rule #131).
- Nothing can leapfrog on tags alone. An additive term with a large
weight would let a near-zero-similarity artist outrank a strong match
for sharing one popular tag, which reads as noise.
- Weight 0 restores pure similarity order bit-for-bit, so the operator's
knob has a real off position.
overlap = Σ(shared) candWeight × normalizedTasteWeight ÷ Σ(all) candWeight.
Normalizing the taste side by the user's strongest tag makes the score
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). Dividing by the candidate's own mass makes it comparable across
candidates, so a densely-tagged artist can't win on tag count alone.
Applied to the whole over-fetched pool BEFORE selectSuggestions, so the
rotation and diversity rules operate on blended scores — boosting only the
twelve already chosen by similarity would leave the re-ranking undone.
A query failure is returned, NOT degraded past. Graceful degradation is
for expected absence (no taste profile, no cached tags) and both are
handled explicitly as empty inputs; swallowing a real error would hide a
broken DB behind a subtly worse ranking that nothing reports.
Migration 0051 adds a FOURTH tuning scope rather than columns on
taste_tuning, because snooze_days lives here too and a snooze must never
be read as taste signal (#2374) — filing it under 'taste' would put it one
careless join from the leak that design forbids. Expanding
recommendation_tuning_audit's CHECK is in the same migration per rule #36,
and a test asserts the audit row lands, which is what would catch its
absence.
snooze_days moves out of a Go constant onto the tuning card (rule #25),
closing the deferral from #2374.
Tag-overlap tests use deliberately SKEWED fixtures: an evenly-matching pool
cannot exercise a re-ranking, since every candidate gets the same
multiplier and the order is unchanged whether the blend works or not.
Admin UI + client attribution follow in this batch — rule #27.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
157 lines
5.9 KiB
Go
157 lines
5.9 KiB
Go
// 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
|
||
}
|