feat(discover): seed request suggestions from the taste profile — #2372
The Discover request surface was the one recommendation surface still on its M5c implementation from early May. #796's taste profile, #1488's taste_unheard bucket and #1490's folksonomy enrichment all modernized in-library surfaces; this one was never in scope for any of them, so it still projected raw likes + plays through artist_similarity_unmatched. Two defects fall out of that signal, `5*liked + Σexp(-age/halflife)` summed over every play of the artist. It is unbounded, and contribution is signal × similarity — so a handful of heavily-played artists monopolize all twelve slots, and their share GROWS the more the user listens. The surface entrenched harder the better it knew you, which is exactly backwards and matches the reported "goes stale once it has a strong signal of your taste". It also counted every play_event with no was_skipped filter, so skipping an artist repeatedly INCREASED its signal and pushed more of its neighbours at the user. ListMostPlayedTracksForUser and the taste engine both filter skips; this query was the odd one out. Seeds now come from taste_profile_artists.weight, which the taste engine has already engagement-graded, time-decayed and signed — an artist the user drifted away from stops contributing instead of accumulating forever, and can even contribute negatively. Tiered per rule #131 rather than hard-switched: tier 1 is the profile, tier 2 is likes + completed plays for a user who has no profile rows yet (new account, or before the first daily recompute), so the surface never empties. The old unfiltered-play signal is gone, not kept behind a toggle. The signal is also log-damped, so one artist cannot take every slot even when its weight dwarfs the rest. $2 stays wired to the tier-2 decay: it is genuinely still used there, and dropping the parameter would have changed the generated signature. sqlc's image is not on this workstation and the change preserves the query signature exactly — same three params, same seven columns — so only the embedded SQL const moves. Both copies are edited and verified byte-identical rather than pulling a container onto the operator's machine; a malformed query fails the integration lane loudly, which is the real check either way. Four integration tests cover what changed: a taste weight alone seeds with no like or play; tier 2 does not run alongside tier 1; a non-positive weight never seeds (guarded by a second positive row, so an empty tier 1 can't make it pass for the wrong reason); and skip-only history seeds nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,14 @@
|
||||
// suggestions.go is the M5c per-user artist-suggestion service. Reads
|
||||
// the user's likes + plays, projects them through artist_similarity_unmatched
|
||||
// via a single CTE, returns top-N candidates with top-3 attribution seeds
|
||||
// resolved to artist names.
|
||||
// suggestions.go is the per-user artist-suggestion service behind the
|
||||
// Discover request surface. Seeds from the taste profile (falling back to
|
||||
// likes + completed plays for a user who has none yet), projects those seeds
|
||||
// through artist_similarity_unmatched via a single CTE, and returns top-N
|
||||
// out-of-library candidates with top-3 attribution seeds resolved to names.
|
||||
//
|
||||
// Originally M5c, which seeded from raw likes + plays. That signal grew
|
||||
// without bound and counted skips as engagement, so a few heavily-played
|
||||
// artists monopolized every slot and the surface entrenched harder the more
|
||||
// the user listened. Reworked to seed from the taste profile in issue #2367;
|
||||
// see internal/db/queries/recommendation.sql for the tiering.
|
||||
package recommendation
|
||||
|
||||
import (
|
||||
@@ -32,8 +39,12 @@ type SeedContribution struct {
|
||||
}
|
||||
|
||||
// SuggestArtists returns top-N artist suggestions for the user. limit is
|
||||
// capped at 50 (default 12 when out of range); halfLifeDays is the
|
||||
// recency-decay half-life for plays (default 30, operator-tunable).
|
||||
// capped at 50 (default 12 when out of range).
|
||||
//
|
||||
// halfLifeDays (default 30) is the recency-decay half-life for the TIER-2
|
||||
// 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) {
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 12
|
||||
|
||||
@@ -330,3 +330,145 @@ func TestSuggestArtists_EmptyForNewUser(t *testing.T) {
|
||||
t.Errorf("len = %d, want 0 (new user has no signal)", len(out))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Slice 1 (#2372): taste-profile seeding, tiering, and the skip fix ---
|
||||
|
||||
func setTasteWeight(t *testing.T, pool *pgxpool.Pool, userID, artistID pgtype.UUID, weight float64) {
|
||||
t.Helper()
|
||||
if _, err := pool.Exec(context.Background(),
|
||||
`INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (user_id, artist_id) DO UPDATE SET weight = EXCLUDED.weight`,
|
||||
userID, artistID, weight,
|
||||
); err != nil {
|
||||
t.Fatalf("set taste weight: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func insertSkippedPlayEvent(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
var sessionID pgtype.UUID
|
||||
if err := pool.QueryRow(ctx,
|
||||
`INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id)
|
||||
VALUES ($1, $2, $2, 'skip-test') RETURNING id`,
|
||||
userID, startedAt,
|
||||
).Scan(&sessionID); err != nil {
|
||||
t.Fatalf("insert play_session: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx,
|
||||
`INSERT INTO play_events (user_id, track_id, session_id, started_at, was_skipped)
|
||||
VALUES ($1, $2, $3, $4, true)`,
|
||||
userID, trackID, sessionID, startedAt,
|
||||
); err != nil {
|
||||
t.Fatalf("insert skipped play_event: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 1: a taste-profile weight alone seeds a suggestion, with no like and no
|
||||
// play on the seed artist. Before #2367 the profile was ignored entirely here.
|
||||
func TestSuggestArtists_TasteProfileWeightSeedsTier1(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
user := seedUser(t, pool, "alice")
|
||||
seed := seedArtist(t, pool, "Profile Seed", "")
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatalf("SuggestArtists: %v", err)
|
||||
}
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("len = %d, want 1 (taste weight alone should seed)", len(out))
|
||||
}
|
||||
if out[0].MBID != "out-mbid" {
|
||||
t.Errorf("mbid = %q, want out-mbid", out[0].MBID)
|
||||
}
|
||||
if out[0].Score <= 0 {
|
||||
t.Errorf("score = %v, want > 0", out[0].Score)
|
||||
}
|
||||
}
|
||||
|
||||
// Once the profile has any positive row, tier 2 is not consulted — so an artist
|
||||
// the user played but that the taste engine did not keep does NOT seed. That is
|
||||
// the point of the rewrite: the taste engine decides what counts as affinity.
|
||||
func TestSuggestArtists_TasteProfileSupersedesRawPlays(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
user := seedUser(t, pool, "alice")
|
||||
kept := seedArtist(t, pool, "Kept By Taste", "")
|
||||
dropped := seedArtist(t, pool, "Dropped By Taste", "")
|
||||
|
||||
setTasteWeight(t, pool, user.ID, kept.ID, 3.0)
|
||||
|
||||
// `dropped` has real completed plays but no profile row.
|
||||
album := seedAlbumForArtist(t, pool, dropped.ID, "Album")
|
||||
track := seedTrackOnAlbum(t, pool, album.ID, dropped.ID, "Track")
|
||||
insertPlayEvent(t, pool, user.ID, track.ID, time.Now().Add(-1*time.Hour))
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatalf("SuggestArtists: %v", err)
|
||||
}
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("len = %d, want 1 (tier 2 must not run alongside tier 1)", len(out))
|
||||
}
|
||||
if out[0].MBID != "kept-cand" {
|
||||
t.Errorf("mbid = %q, want kept-cand", out[0].MBID)
|
||||
}
|
||||
}
|
||||
|
||||
// A non-positive taste weight is not affinity. Guarded by a second, positive
|
||||
// row so tier 1 stays active — otherwise an empty tier 1 would fall through to
|
||||
// tier 2 and the assertion would pass for the wrong reason.
|
||||
func TestSuggestArtists_NonPositiveTasteWeightDoesNotSeed(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
user := seedUser(t, pool, "alice")
|
||||
positive := seedArtist(t, pool, "Still Liked", "")
|
||||
abandoned := seedArtist(t, pool, "Abandoned", "")
|
||||
|
||||
setTasteWeight(t, pool, user.ID, positive.ID, 2.0)
|
||||
setTasteWeight(t, pool, user.ID, abandoned.ID, -1.5)
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatalf("SuggestArtists: %v", err)
|
||||
}
|
||||
for _, s := range out {
|
||||
if s.MBID == "neg-cand" {
|
||||
t.Fatalf("negative-weight artist seeded a suggestion: %+v", s)
|
||||
}
|
||||
}
|
||||
if len(out) != 1 || out[0].MBID != "pos-cand" {
|
||||
t.Errorf("out = %+v, want only pos-cand", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 2 counts COMPLETED plays only. Previously every play_event counted, so
|
||||
// skipping an artist repeatedly increased its signal and pushed more of its
|
||||
// neighbours at the user (#2367 mechanism 3).
|
||||
func TestSuggestArtists_SkippedPlaysDoNotSeedTier2(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
user := seedUser(t, pool, "alice")
|
||||
skipped := seedArtist(t, pool, "Only Skipped", "")
|
||||
|
||||
album := seedAlbumForArtist(t, pool, skipped.ID, "Album")
|
||||
track := seedTrackOnAlbum(t, pool, album.ID, skipped.ID, "Track")
|
||||
for i := 0; i < 5; i++ {
|
||||
insertSkippedPlayEvent(t, pool, user.ID, track.ID, time.Now().Add(-time.Duration(i+1)*time.Hour))
|
||||
}
|
||||
seedUnmatched(t, pool, skipped.ID, "skip-cand", "Skip Candidate", 0.9)
|
||||
|
||||
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
||||
if err != nil {
|
||||
t.Fatalf("SuggestArtists: %v", err)
|
||||
}
|
||||
if len(out) != 0 {
|
||||
t.Errorf("len = %d, want 0 (skips are not affinity): %+v", len(out), out)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user