feat(discover): rank suggestions by taste-tag overlap — #2377 (server)
test-go / test (push) Successful in 1m0s
test-go / integration (push) Failing after 4m55s

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>
This commit is contained in:
2026-08-02 20:31:12 -04:00
co-authored by Claude Opus 5
parent 7315e37c15
commit 799dab029a
16 changed files with 1085 additions and 29 deletions
@@ -4,6 +4,7 @@ import (
"context"
"testing"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
@@ -280,3 +281,141 @@ func TestCandidateTags_CoverageCountsProcessedAndTagged(t *testing.T) {
t.Errorf("with_tags = %d, want 2 ('none' excluded)", got.WithTags)
}
}
// --- Slice 6 (#2377): the taste-tag blend, end to end ---
//
// The pure tests in tagoverlap_test.go cover the scoring maths. These cover the
// wiring the pure tests cannot reach: that loadTagInputs actually reads both
// sides from the DB and that the blend reaches the returned deck.
func seedTasteTag(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID, tag string, weight float64) {
t.Helper()
if _, err := pool.Exec(context.Background(),
`INSERT INTO taste_profile_tags (user_id, tag, weight) VALUES ($1, $2, $3)
ON CONFLICT (user_id, tag) DO UPDATE SET weight = EXCLUDED.weight`,
userID, tag, weight,
); err != nil {
t.Fatalf("seedTasteTag: %v", err)
}
}
func seedCandidateTag(t *testing.T, pool *pgxpool.Pool, mbid, tag string, weight float64) {
t.Helper()
if err := dbq.New(pool).InsertCandidateArtistTag(context.Background(),
dbq.InsertCandidateArtistTagParams{CandidateMbid: mbid, Tag: tag, Weight: weight},
); err != nil {
t.Fatalf("seedCandidateTag: %v", err)
}
}
// twoCandidatePool wires a liked seed with two unmatched neighbours: "loud"
// scores higher on similarity, "match" lower. Skewed on purpose — with equal
// similarity the reorder assertion below could not fail.
func twoCandidatePool(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID) {
t.Helper()
seed := seedArtist(t, pool, "Seed", "")
likeArtist(t, pool, userID, seed.ID)
seedUnmatched(t, pool, seed.ID, "loud", "Loud Neighbour", 0.9)
seedUnmatched(t, pool, seed.ID, "match", "Taste Match", 0.6)
}
func TestSuggestArtists_TasteTagMatchOvertakesStrongerSimilarity(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
twoCandidatePool(t, pool, user.ID)
seedTasteTag(t, pool, user.ID, "shoegaze", 10)
seedCandidateTag(t, pool, "match", "shoegaze", 1.0)
seedCandidateTag(t, pool, "loud", "death metal", 1.0)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 1.0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
if len(out) != 2 {
t.Fatalf("len = %d, want 2", len(out))
}
if out[0].MBID != "match" {
t.Errorf("first = %q, want match (0.6×2 beats 0.9×1)", out[0].MBID)
}
// And it explains itself.
if len(out[0].MatchedTags) != 1 || out[0].MatchedTags[0] != "shoegaze" {
t.Errorf("matched tags = %v, want [shoegaze]", out[0].MatchedTags)
}
if out[1].MatchedTags != nil {
t.Errorf("non-matching candidate got matched tags: %v", out[1].MatchedTags)
}
}
// The same fixture with the knob at 0 must return pure similarity order — the
// operator's off switch, verified against a real DB rather than assumed.
func TestSuggestArtists_ZeroTagWeightKeepsSimilarityOrder(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
twoCandidatePool(t, pool, user.ID)
seedTasteTag(t, pool, user.ID, "shoegaze", 10)
seedCandidateTag(t, pool, "match", "shoegaze", 1.0)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
if out[0].MBID != "loud" {
t.Errorf("first = %q, want loud (tag term disabled)", out[0].MBID)
}
}
// A user with no taste profile must still get a full deck in similarity order:
// the cold-start path, which is every user's first days (rule #131).
func TestSuggestArtists_NoTasteTagsStillReturnsSimilarityOrder(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
twoCandidatePool(t, pool, user.ID)
// Candidate tags exist, but the user has no taste tags to match them.
seedCandidateTag(t, pool, "match", "shoegaze", 1.0)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 1.0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
if len(out) != 2 {
t.Fatalf("len = %d, want 2 (nothing dropped)", len(out))
}
if out[0].MBID != "loud" {
t.Errorf("first = %q, want loud", out[0].MBID)
}
}
// An untagged candidate must never be dropped or sunk just because another
// candidate has tags — permanently-partial coverage (#2376) must not become a
// permanent ranking penalty.
func TestSuggestArtists_UntaggedCandidateSurvivesAlongsideTagged(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
twoCandidatePool(t, pool, user.ID)
seedTasteTag(t, pool, user.ID, "shoegaze", 10)
seedCandidateTag(t, pool, "match", "shoegaze", 1.0)
// "loud" is deliberately left with NO cached tags at all.
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 1.0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
if len(out) != 2 {
t.Fatalf("len = %d, want 2 — the untagged candidate must still appear", len(out))
}
var loud *ArtistSuggestion
for i := range out {
if out[i].MBID == "loud" {
loud = &out[i]
}
}
if loud == nil {
t.Fatal("untagged candidate vanished from the deck")
}
if loud.Score != 0.9 {
t.Errorf("untagged score = %v, want 0.9 unchanged", loud.Score)
}
}
+75 -1
View File
@@ -31,6 +31,15 @@ type ArtistSuggestion struct {
Name string
Score float64
Attribution []SeedContribution
// MatchedTags are the candidate's own tags that overlap the user's taste
// profile, strongest first (max 3) — the "matches: shoegaze, melancholic"
// explanation (#2377). Empty when the candidate has no cached tags, which
// is common and not an error: coverage is permanently partial (#2376).
MatchedTags []string
// TagOverlap is the [0,1] share of the candidate's tag mass the user likes.
// Exposed for the admin tuning lab — seeing the term's actual distribution
// is how the operator picks a weight rather than guessing at one.
TagOverlap float64
}
// SeedContribution is one of the top-3 contributing seeds for a candidate.
@@ -49,7 +58,10 @@ type SeedContribution struct {
// seed path only — the likes + completed-plays fallback used while the user
// has no taste-profile rows yet. Once the profile is populated it seeds
// instead, carrying its own decay, so this knob stops applying.
func SuggestArtists(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID, halfLifeDays float64, limit int) ([]ArtistSuggestion, error) {
func SuggestArtists(
ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID,
halfLifeDays float64, limit int, tagOverlapWeight float64,
) ([]ArtistSuggestion, error) {
if limit <= 0 || limit > 50 {
limit = 12
}
@@ -117,9 +129,71 @@ func SuggestArtists(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID,
Attribution: attribution,
})
}
// Taste-tag term (#2377). Applied to the whole over-fetched pool BEFORE
// selection, so the rotation and diversity rules in selectSuggestions
// operate on taste-blended scores — boosting only the twelve already
// chosen by similarity would leave the actual re-ranking undone.
//
// A query error here is returned, NOT degraded past. Graceful degradation
// is for expected absence — no taste profile yet, no cached tags for a
// candidate — and both of those are handled explicitly as empty inputs
// below. A failing query is neither: swallowing it would hide a broken DB
// behind a subtly worse ranking that nothing reports.
tasteTags, candTags, err := loadTagInputs(ctx, q, userID, out)
if err != nil {
return nil, err
}
out = applyTagOverlap(out, candTags, tasteTags, tagOverlapWeight)
return selectSuggestions(out, limit, rotationDay(time.Now())), nil
}
// tasteTagLimit caps how many of the user's taste tags participate. The
// profile's long tail is near-zero weight and contributes nothing after
// normalization, so this bounds the query rather than the meaning.
const tasteTagLimit = 50
// loadTagInputs fetches both sides of the overlap comparison: the user's taste
// tags and the cached tags for exactly the candidates in this pool.
func loadTagInputs(
ctx context.Context, q *dbq.Queries, userID pgtype.UUID, pool []ArtistSuggestion,
) (TagWeights, map[string]TagWeights, error) {
tasteRows, err := q.ListTasteProfileTagsForUser(ctx, dbq.ListTasteProfileTagsForUserParams{
UserID: userID,
Limit: tasteTagLimit,
})
if err != nil {
return nil, nil, fmt.Errorf("suggest: taste tags: %w", err)
}
// No taste tags is a cold start, not a failure — return early and skip the
// candidate-tag fetch entirely, since nothing could match.
if len(tasteRows) == 0 {
return nil, nil, nil
}
taste := make(TagWeights, len(tasteRows))
for _, r := range tasteRows {
taste[r.Tag] = r.Weight
}
mbids := make([]string, 0, len(pool))
for _, s := range pool {
mbids = append(mbids, s.MBID)
}
tagRows, err := q.ListCandidateArtistTagsForMbids(ctx, mbids)
if err != nil {
return nil, nil, fmt.Errorf("suggest: candidate tags: %w", err)
}
byCandidate := make(map[string]TagWeights, len(pool))
for _, r := range tagRows {
if byCandidate[r.CandidateMbid] == nil {
byCandidate[r.CandidateMbid] = TagWeights{}
}
byCandidate[r.CandidateMbid][r.Tag] = r.Weight
}
return taste, byCandidate, nil
}
// Pool multiplier: how many scored candidates to fetch per slot shown, so the
// rotation has somewhere to rotate. 4x keeps a day's deck genuinely different
// from yesterday's without pulling the whole long tail (whose scores are noise)
@@ -147,7 +147,7 @@ func TestSuggestArtists_LikesAndPlaysContributeToScore(t *testing.T) {
seedUnmatched(t, pool, seedA.ID, "out-mbid", "Outsider", 0.9)
seedUnmatched(t, pool, seedB.ID, "out-mbid", "Outsider", 0.5)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
@@ -174,7 +174,7 @@ func TestSuggestArtists_Top12Cap(t *testing.T) {
for i := 0; i < 30; i++ {
seedUnmatched(t, pool, seed.ID, fmt.Sprintf("mbid-%02d", i), fmt.Sprintf("Artist %02d", i), 0.99-float64(i)*0.01)
}
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
@@ -195,7 +195,7 @@ func TestSuggestArtists_AttributionTopThree(t *testing.T) {
likeArtist(t, pool, user.ID, seeds[i].ID)
seedUnmatched(t, pool, seeds[i].ID, "shared-mbid", "Shared", 0.9-float64(i)*0.1)
}
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
@@ -227,7 +227,7 @@ func TestSuggestArtists_RecencyDecayDownweightsOldPlays(t *testing.T) {
seedUnmatched(t, pool, recentSeed.ID, "cand", "Cand", 0.5)
seedUnmatched(t, pool, oldSeed.ID, "cand", "Cand", 0.5)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
@@ -255,7 +255,7 @@ func TestSuggestArtists_FiltersInLibraryCandidates(t *testing.T) {
seedArtist(t, pool, "InLib", inLibMBID)
seedUnmatched(t, pool, seed.ID, inLibMBID, "InLib", 0.9)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
@@ -279,7 +279,7 @@ func TestSuggestArtists_FiltersAlreadyRequested(t *testing.T) {
t.Fatalf("CreateLidarrRequest: %v", err)
}
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
@@ -310,7 +310,7 @@ func TestSuggestArtists_RejectedRequestStillShown(t *testing.T) {
t.Fatalf("RejectLidarrRequest: %v", err)
}
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
@@ -322,7 +322,7 @@ func TestSuggestArtists_RejectedRequestStillShown(t *testing.T) {
func TestSuggestArtists_EmptyForNewUser(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "newbie")
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
@@ -374,7 +374,7 @@ func TestSuggestArtists_TasteProfileWeightSeedsTier1(t *testing.T) {
setTasteWeight(t, pool, user.ID, seed.ID, 4.0)
seedUnmatched(t, pool, seed.ID, "out-mbid", "Outsider", 0.9)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
@@ -408,7 +408,7 @@ func TestSuggestArtists_TasteProfileSupersedesRawPlays(t *testing.T) {
seedUnmatched(t, pool, kept.ID, "kept-cand", "Kept Candidate", 0.9)
seedUnmatched(t, pool, dropped.ID, "dropped-cand", "Dropped Candidate", 0.9)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
@@ -435,7 +435,7 @@ func TestSuggestArtists_NonPositiveTasteWeightDoesNotSeed(t *testing.T) {
seedUnmatched(t, pool, positive.ID, "pos-cand", "Positive Candidate", 0.9)
seedUnmatched(t, pool, abandoned.ID, "neg-cand", "Negative Candidate", 0.9)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
@@ -464,7 +464,7 @@ func TestSuggestArtists_SkippedPlaysDoNotSeedTier2(t *testing.T) {
}
seedUnmatched(t, pool, skipped.ID, "skip-cand", "Skip Candidate", 0.9)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
@@ -518,7 +518,7 @@ func TestSuggestArtists_ActiveSnoozeHidesCandidate(t *testing.T) {
t.Fatalf("SnoozeSuggestion: %v", err)
}
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
@@ -534,7 +534,7 @@ func TestSuggestArtists_ExpiredSnoozeShowsCandidateAgain(t *testing.T) {
seedOneCandidate(t, pool, user.ID, "expired-mbid", "Returning Artist")
snoozeUntil(t, pool, user.ID, "expired-mbid", "Returning Artist", time.Now().Add(-time.Hour))
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
@@ -560,14 +560,14 @@ func TestSuggestArtists_SnoozeIsPerUser(t *testing.T) {
snoozeUntil(t, pool, alice.ID, "shared-mbid", "Shared Candidate", time.Now().Add(24*time.Hour))
aliceOut, err := SuggestArtists(context.Background(), pool, alice.ID, 30, 12)
aliceOut, err := SuggestArtists(context.Background(), pool, alice.ID, 30, 12, 0)
if err != nil {
t.Fatalf("SuggestArtists(alice): %v", err)
}
if len(aliceOut) != 0 {
t.Errorf("alice len = %d, want 0 (she snoozed it)", len(aliceOut))
}
bobOut, err := SuggestArtists(context.Background(), pool, bob.ID, 30, 12)
bobOut, err := SuggestArtists(context.Background(), pool, bob.ID, 30, 12, 0)
if err != nil {
t.Fatalf("SuggestArtists(bob): %v", err)
}
@@ -604,7 +604,7 @@ func TestUnsnoozeSuggestion_RestoresImmediately(t *testing.T) {
t.Errorf("repeat rows = %d, want 0", rows)
}
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
+156
View File
@@ -0,0 +1,156 @@
// 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
}
+244
View File
@@ -0,0 +1,244 @@
package recommendation
import (
"fmt"
"testing"
)
// tagOverlap / applyTagOverlap are pure, so slice 6's ranking rules are covered
// in the fast lane rather than behind the integration gate.
//
// Fixtures here are deliberately SKEWED — candidates that match the taste
// profile to clearly different degrees. An evenly-matching pool cannot exercise
// a re-ranking at all: every candidate gets the same multiplier and the order is
// unchanged whether the blend works or not. Slice 2's first diversity test had
// exactly that defect, so it's called out explicitly here.
func TestTagOverlap_FullMatchScoresOne(t *testing.T) {
// Every unit of the candidate's tag mass sits on the user's single
// strongest tag → the whole mass matches at full strength.
got, matched := tagOverlap(
TagWeights{"shoegaze": 1.0},
TagWeights{"shoegaze": 5.0},
)
if got != 1.0 {
t.Errorf("overlap = %v, want 1.0", got)
}
if len(matched) != 1 || matched[0] != "shoegaze" {
t.Errorf("matched = %v, want [shoegaze]", matched)
}
}
func TestTagOverlap_NoSharedTagsScoresZero(t *testing.T) {
got, matched := tagOverlap(
TagWeights{"death metal": 1.0},
TagWeights{"shoegaze": 5.0},
)
if got != 0 {
t.Errorf("overlap = %v, want 0", got)
}
if matched != nil {
t.Errorf("matched = %v, want nil", matched)
}
}
// Half the candidate's mass is on a matching tag, and that tag is the user's
// strongest → 0.5.
func TestTagOverlap_PartialMassMatchIsProportional(t *testing.T) {
got, _ := tagOverlap(
TagWeights{"shoegaze": 1.0, "death metal": 1.0},
TagWeights{"shoegaze": 5.0},
)
if got != 0.5 {
t.Errorf("overlap = %v, want 0.5", got)
}
}
// Matching a tag the user barely likes must score below matching one they love.
func TestTagOverlap_WeakTasteTagScoresLowerThanStrong(t *testing.T) {
taste := TagWeights{"shoegaze": 10.0, "polka": 1.0}
strong, _ := tagOverlap(TagWeights{"shoegaze": 1.0}, taste)
weak, _ := tagOverlap(TagWeights{"polka": 1.0}, taste)
if !(strong > weak) {
t.Errorf("strong=%v weak=%v — a favourite tag must outscore a marginal one", strong, weak)
}
if weak != 0.1 { // 1.0 * (1/10) / 1.0
t.Errorf("weak = %v, want 0.1", weak)
}
}
// THE degradation path. Tag coverage for out-of-library candidates is
// permanently partial (#2376) and cold-start users have no taste tags, so both
// must be scored 0 — never negative, never dropped (rule #131).
func TestTagOverlap_EmptyEitherSideIsZeroNotNegative(t *testing.T) {
cases := []struct {
name string
candidate, taste TagWeights
}{
{"no candidate tags", nil, TagWeights{"shoegaze": 5}},
{"no taste tags", TagWeights{"shoegaze": 1}, nil},
{"both empty", nil, nil},
{"taste weights all zero", TagWeights{"shoegaze": 1}, TagWeights{"shoegaze": 0}},
{"candidate weights all zero", TagWeights{"shoegaze": 0}, TagWeights{"shoegaze": 5}},
}
for _, c := range cases {
got, matched := tagOverlap(c.candidate, c.taste)
if got != 0 {
t.Errorf("%s: overlap = %v, want 0", c.name, got)
}
if matched != nil {
t.Errorf("%s: matched = %v, want nil", c.name, matched)
}
}
}
// A densely-tagged artist must not out-score a focused one merely by having
// more tags — that's what dividing by the candidate's own mass buys.
func TestTagOverlap_IsNotAPopularityContest(t *testing.T) {
taste := TagWeights{"shoegaze": 5.0}
focused, _ := tagOverlap(TagWeights{"shoegaze": 1.0}, taste)
sprawling, _ := tagOverlap(TagWeights{
"shoegaze": 1.0, "rock": 1.0, "alternative": 1.0, "90s": 1.0,
}, taste)
if !(focused > sprawling) {
t.Errorf("focused=%v sprawling=%v — extra unmatched tags must dilute, not add",
focused, sprawling)
}
}
// Taste weights accumulate with listening, so raw magnitudes differ wildly
// between a new user and a heavy one while meaning the same thing. Normalizing
// by the user's own strongest tag is what makes the score comparable.
func TestTagOverlap_IsInvariantToTasteMagnitude(t *testing.T) {
candidate := TagWeights{"shoegaze": 1.0, "dream pop": 1.0}
newUser, _ := tagOverlap(candidate, TagWeights{"shoegaze": 2.0, "polka": 1.0})
heavyUser, _ := tagOverlap(candidate, TagWeights{"shoegaze": 2000.0, "polka": 1000.0})
if newUser != heavyUser {
t.Errorf("newUser=%v heavyUser=%v — scaling all taste weights must not change the result",
newUser, heavyUser)
}
}
func TestTagOverlap_MatchedTagsAreOrderedAndCapped(t *testing.T) {
got, matched := tagOverlap(
TagWeights{"a": 0.2, "b": 0.9, "c": 0.5, "d": 0.7},
TagWeights{"a": 10, "b": 10, "c": 10, "d": 10},
)
if got <= 0 {
t.Fatalf("overlap = %v, want > 0", got)
}
// All taste weights equal, so candidate weight decides: b(.9) d(.7) c(.5).
want := []string{"b", "d", "c"}
if fmt.Sprint(matched) != fmt.Sprint(want) {
t.Errorf("matched = %v, want %v (strongest first, capped at %d)",
matched, want, maxMatchedTags)
}
}
// --- applyTagOverlap ---
func poolFor(specs ...struct {
mbid string
score float64
}) []ArtistSuggestion {
out := make([]ArtistSuggestion, 0, len(specs))
for _, s := range specs {
out = append(out, ArtistSuggestion{MBID: s.mbid, Name: s.mbid, Score: s.score})
}
return out
}
type spec = struct {
mbid string
score float64
}
// The payoff, and the reason the fixture is skewed: a taste-matching candidate
// that started BELOW another must be able to overtake it. With an evenly
// matching pool this assertion could not fail.
func TestApplyTagOverlap_TasteMatchOvertakesAStrongerNonMatch(t *testing.T) {
pool := poolFor(spec{"loud", 1.0}, spec{"match", 0.7})
candTags := map[string]TagWeights{
"loud": {"death metal": 1.0},
"match": {"shoegaze": 1.0},
}
taste := TagWeights{"shoegaze": 5.0}
got := applyTagOverlap(pool, candTags, taste, 1.0)
if got[0].MBID != "match" {
t.Errorf("first = %q, want match (0.7×2 = 1.4 beats 1.0×1)", got[0].MBID)
}
if got[0].MatchedTags == nil {
t.Error("matched tags not stamped onto the winner")
}
}
// An untagged candidate keeps its score EXACTLY. This is the multiplicative
// blend's whole safety argument: partial coverage costs a candidate nothing.
func TestApplyTagOverlap_UntaggedCandidateScoreIsUnchanged(t *testing.T) {
pool := poolFor(spec{"untagged", 0.9})
got := applyTagOverlap(pool, map[string]TagWeights{}, TagWeights{"shoegaze": 5}, 1.0)
if got[0].Score != 0.9 {
t.Errorf("score = %v, want 0.9 exactly (no tags must not penalise)", got[0].Score)
}
if got[0].MatchedTags != nil {
t.Errorf("matched = %v, want nil", got[0].MatchedTags)
}
}
// Weight 0 is the operator's off switch: the ordering must be bit-for-bit the
// pre-slice-6 result, not merely similar.
func TestApplyTagOverlap_ZeroWeightIsAnExactNoOp(t *testing.T) {
pool := poolFor(spec{"a", 1.0}, spec{"b", 0.7})
candTags := map[string]TagWeights{"b": {"shoegaze": 1.0}}
got := applyTagOverlap(pool, candTags, TagWeights{"shoegaze": 5.0}, 0)
if got[0].MBID != "a" || got[0].Score != 1.0 || got[1].Score != 0.7 {
t.Errorf("weight 0 changed the pool: %+v", got)
}
// And it must not stamp matched tags either — the UI would otherwise
// explain a boost that never happened.
if got[1].MatchedTags != nil {
t.Errorf("matched tags stamped while the feature is off: %v", got[1].MatchedTags)
}
}
// A user with no taste profile is the cold-start case: every candidate scores
// the same multiplier of 1, so similarity order must survive intact.
func TestApplyTagOverlap_NoTasteProfileLeavesOrderIntact(t *testing.T) {
pool := poolFor(spec{"a", 1.0}, spec{"b", 0.7}, spec{"c", 0.4})
candTags := map[string]TagWeights{"c": {"shoegaze": 1.0}}
got := applyTagOverlap(pool, candTags, nil, 1.0)
for i, want := range []string{"a", "b", "c"} {
if got[i].MBID != want {
t.Fatalf("order = %v..., want a b c", got[i].MBID)
}
}
}
// Bounded boost: even a perfect match at the maximum weight can only multiply
// by (1 + w), so a candidate cannot be catapulted arbitrarily far.
func TestApplyTagOverlap_BoostIsBoundedByOnePlusWeight(t *testing.T) {
pool := poolFor(spec{"perfect", 1.0})
got := applyTagOverlap(pool,
map[string]TagWeights{"perfect": {"shoegaze": 1.0}},
TagWeights{"shoegaze": 5.0}, 2.0)
if got[0].Score != 3.0 {
t.Errorf("score = %v, want 3.0 (1.0 × (1 + 2×1))", got[0].Score)
}
if got[0].TagOverlap != 1.0 {
t.Errorf("TagOverlap = %v, want 1.0", got[0].TagOverlap)
}
}
// Equal blended scores must resolve deterministically, or two candidates could
// swap between requests inside one day — the exact churn the daily rotation
// (#2373) exists to prevent.
func TestApplyTagOverlap_TiesBreakDeterministically(t *testing.T) {
first := applyTagOverlap(poolFor(spec{"zzz", 1.0}, spec{"aaa", 1.0}),
map[string]TagWeights{}, TagWeights{"x": 1}, 1.0)
second := applyTagOverlap(poolFor(spec{"aaa", 1.0}, spec{"zzz", 1.0}),
map[string]TagWeights{}, TagWeights{"x": 1}, 1.0)
if first[0].MBID != "aaa" || second[0].MBID != "aaa" {
t.Errorf("tie order not deterministic: %q then %q", first[0].MBID, second[0].MBID)
}
}