feat(discover): rank suggestions by taste-tag overlap — #2377 (server)
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user