From 6c26ba807ed9619aa9b133e2eb17e03d7e3348e2 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 12 Jun 2026 00:05:50 -0400 Subject: [PATCH] =?UTF-8?q?feat(taste):=20phase=202b=20=E2=80=94=20taste?= =?UTF-8?q?=5Foverlap=20candidate=20arm=20(#796)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2a re-ranks the existing pool by TasteMatch; this ensures taste-relevant tracks ARE in the pool. Adds a 6th arm to LoadRadioCandidatesV2: in-library tracks by the user's top positively-weighted taste-profile artists ($10 K, weight > 0, deterministic weight-DESC,id order so it doesn't reintroduce same-day nondeterminism). Pool-inclusion only (sim_score 0) — TasteMatch already scores the fit. Empty for cold-start users (no profile). - CandidateSourceLimits.TasteOverlap; default 20 (radio), 80 for For-You via systemForYouSourceLimits. - You-might-like deliberately sets TasteOverlap=0: it surfaces NOT-actively- engaged artists, so flooding its pool with top-taste (mostly already-played) artists would just feed the read-time dedup. - Test: positive-weight artist's track enters via the arm; negative-weight one is excluded (weight > 0). Existing pool tests unaffected (no profile seeded). Deferred within 2b: profile-seeded For-You — marginal given the arm + TasteMatch already inject taste broadly (top-played seed ≈ top-taste artist). Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/db/dbq/recommendation.sql.go | 23 +++++++- internal/db/queries/recommendation.sql | 21 ++++++- internal/playlists/system.go | 4 ++ internal/playlists/you_might_like.go | 8 ++- internal/recommendation/candidates.go | 6 ++ internal/recommendation/candidates_v2_test.go | 55 +++++++++++++++++++ 6 files changed, 112 insertions(+), 5 deletions(-) diff --git a/internal/db/dbq/recommendation.sql.go b/internal/db/dbq/recommendation.sql.go index f508a031..add1e34e 100644 --- a/internal/db/dbq/recommendation.sql.go +++ b/internal/db/dbq/recommendation.sql.go @@ -737,6 +737,17 @@ likes_overlap AS ( ORDER BY random() LIMIT $8 ), +taste_overlap AS ( + SELECT t.id AS track_id, 0.0::float8 AS sim_score + FROM taste_profile_artists tpa + JOIN tracks t ON t.artist_id = tpa.artist_id + WHERE tpa.user_id = $1 + AND tpa.weight > 0 + AND t.id NOT IN (SELECT id FROM excluded_ids) + AND t.id <> $2 + ORDER BY tpa.weight DESC, t.id + LIMIT $10 +), random_fill AS ( SELECT t.id AS track_id, 0.0::float8 AS sim_score FROM tracks t @@ -747,6 +758,7 @@ random_fill AS ( UNION SELECT track_id FROM similar_artists UNION SELECT track_id FROM tag_overlap UNION SELECT track_id FROM likes_overlap + UNION SELECT track_id FROM taste_overlap ) ORDER BY random() LIMIT $9 @@ -763,6 +775,7 @@ FROM ( UNION ALL SELECT track_id, sim_score FROM similar_artists UNION ALL SELECT track_id, sim_score FROM tag_overlap UNION ALL SELECT track_id, sim_score FROM likes_overlap + UNION ALL SELECT track_id, sim_score FROM taste_overlap UNION ALL SELECT track_id, sim_score FROM random_fill ) u JOIN tracks t ON t.id = u.track_id @@ -790,6 +803,7 @@ type LoadRadioCandidatesV2Params struct { Limit_3 int32 Limit_4 int32 Limit_5 int32 + Limit_6 int32 } type LoadRadioCandidatesV2Row struct { @@ -801,11 +815,15 @@ type LoadRadioCandidatesV2Row struct { SimilarityScore interface{} } -// M4c: similarity-driven candidate pool. 5-way UNION: +// M4c: similarity-driven candidate pool. 6-way UNION: // // $1 user_id, $2 seed_track_id, $3 recently_played_hours, // $4 exclude (uuid[]), $5 lb_similar K, $6 similar_artists K, -// $7 tag_overlap K, $8 likes_overlap K, $9 random_fill K. +// $7 tag_overlap K, $8 likes_overlap K, $9 random_fill K, +// $10 taste_overlap K (#796 phase 2b — tracks by the user's top +// positively-weighted taste-profile artists, so taste-relevant tracks +// enter the pool even when the similarity/random arms miss them; scored +// in Go via TasteMatch, so sim_score here is 0 pool-inclusion). // // Returns same shape as LoadRadioCandidates plus similarity_score column. func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error) { @@ -819,6 +837,7 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid arg.Limit_3, arg.Limit_4, arg.Limit_5, + arg.Limit_6, ) if err != nil { return nil, err diff --git a/internal/db/queries/recommendation.sql b/internal/db/queries/recommendation.sql index 7611daac..05882dbe 100644 --- a/internal/db/queries/recommendation.sql +++ b/internal/db/queries/recommendation.sql @@ -33,10 +33,14 @@ WHERE t.id <> $2 ); -- name: LoadRadioCandidatesV2 :many --- M4c: similarity-driven candidate pool. 5-way UNION: +-- M4c: similarity-driven candidate pool. 6-way UNION: -- $1 user_id, $2 seed_track_id, $3 recently_played_hours, -- $4 exclude (uuid[]), $5 lb_similar K, $6 similar_artists K, --- $7 tag_overlap K, $8 likes_overlap K, $9 random_fill K. +-- $7 tag_overlap K, $8 likes_overlap K, $9 random_fill K, +-- $10 taste_overlap K (#796 phase 2b — tracks by the user's top +-- positively-weighted taste-profile artists, so taste-relevant tracks +-- enter the pool even when the similarity/random arms miss them; scored +-- in Go via TasteMatch, so sim_score here is 0 pool-inclusion). -- Returns same shape as LoadRadioCandidates plus similarity_score column. WITH @@ -109,6 +113,17 @@ likes_overlap AS ( ORDER BY random() LIMIT $8 ), +taste_overlap AS ( + SELECT t.id AS track_id, 0.0::float8 AS sim_score + FROM taste_profile_artists tpa + JOIN tracks t ON t.artist_id = tpa.artist_id + WHERE tpa.user_id = $1 + AND tpa.weight > 0 + AND t.id NOT IN (SELECT id FROM excluded_ids) + AND t.id <> $2 + ORDER BY tpa.weight DESC, t.id + LIMIT $10 +), random_fill AS ( SELECT t.id AS track_id, 0.0::float8 AS sim_score FROM tracks t @@ -119,6 +134,7 @@ random_fill AS ( UNION SELECT track_id FROM similar_artists UNION SELECT track_id FROM tag_overlap UNION SELECT track_id FROM likes_overlap + UNION SELECT track_id FROM taste_overlap ) ORDER BY random() LIMIT $9 @@ -135,6 +151,7 @@ FROM ( UNION ALL SELECT track_id, sim_score FROM similar_artists UNION ALL SELECT track_id, sim_score FROM tag_overlap UNION ALL SELECT track_id, sim_score FROM likes_overlap + UNION ALL SELECT track_id, sim_score FROM taste_overlap UNION ALL SELECT track_id, sim_score FROM random_fill ) u JOIN tracks t ON t.id = u.track_id diff --git a/internal/playlists/system.go b/internal/playlists/system.go index 1efdba40..b618d9d7 100644 --- a/internal/playlists/system.go +++ b/internal/playlists/system.go @@ -329,6 +329,10 @@ func systemForYouSourceLimits() recommendation.CandidateSourceLimits { TagOverlap: 60, LikesOverlap: 40, RandomFill: 150, + // For-You / You-might-like are the taste-driven surfaces, so pull a + // deep slice of the user's top taste-profile artists into the pool + // (#796 phase 2b). Empty for cold-start users (no profile yet). + TasteOverlap: 80, } } diff --git a/internal/playlists/you_might_like.go b/internal/playlists/you_might_like.go index e2d8b47e..3d40e919 100644 --- a/internal/playlists/you_might_like.go +++ b/internal/playlists/you_might_like.go @@ -89,9 +89,15 @@ func buildYouMightLike( } zeroVec := recommendation.SessionVector{Seed: true} + // You-might-like surfaces in-library artists the user does NOT actively + // engage with, so it deliberately skips the taste_overlap arm — that arm + // pulls top-taste (mostly already-played) artists, which would crowd the + // pool with entities the read-time dedup then strips. For-You/radio keep it. + ymlLimits := systemForYouSourceLimits() + ymlLimits.TasteOverlap = 0 cands, err := recommendation.LoadCandidatesFromSimilarity( ctx, q, userID, seed, 1, zeroVec, - []pgtype.UUID{seed}, systemForYouSourceLimits(), + []pgtype.UUID{seed}, ymlLimits, ) if err != nil { logger.Warn("you-might-like: candidate load failed; skipping", diff --git a/internal/recommendation/candidates.go b/internal/recommendation/candidates.go index ad0d6d39..81c9f6bf 100644 --- a/internal/recommendation/candidates.go +++ b/internal/recommendation/candidates.go @@ -72,6 +72,10 @@ type CandidateSourceLimits struct { TagOverlap int LikesOverlap int RandomFill int + // TasteOverlap (#796 phase 2b): tracks by the user's top positively- + // weighted taste-profile artists. 0 disables the arm (e.g. cold-start + // users have an empty profile, so it contributes nothing anyway). + TasteOverlap int } // DefaultCandidateSourceLimits returns the v1 hardcoded constants per spec. @@ -82,6 +86,7 @@ func DefaultCandidateSourceLimits() CandidateSourceLimits { TagOverlap: 20, LikesOverlap: 20, RandomFill: 30, + TasteOverlap: 20, } } @@ -113,6 +118,7 @@ func LoadCandidatesFromSimilarity( Limit_3: int32(limits.TagOverlap), Limit_4: int32(limits.LikesOverlap), Limit_5: int32(limits.RandomFill), + Limit_6: int32(limits.TasteOverlap), }) if err != nil { return nil, err diff --git a/internal/recommendation/candidates_v2_test.go b/internal/recommendation/candidates_v2_test.go index 294b0e58..0fa19b55 100644 --- a/internal/recommendation/candidates_v2_test.go +++ b/internal/recommendation/candidates_v2_test.go @@ -262,6 +262,61 @@ func TestLoadCandidatesFromSimilarity_DedupTakesMaxScore(t *testing.T) { } } +// TestLoadCandidatesFromSimilarity_TasteOverlapArm (#796 phase 2b): a track by +// a positively-weighted taste-profile artist enters the pool via taste_overlap +// even with every other arm disabled; a negatively-weighted artist's track does +// not (the WHERE weight > 0 filter). +func TestLoadCandidatesFromSimilarity_TasteOverlapArm(t *testing.T) { + f := newFixture(t, 2) // seed + 1 other, both by the fixture artist + seed := f.tracks[0] + target := f.tracks[1] + ctx := context.Background() + + // Fixture artist gets a positive taste weight. + if _, err := f.pool.Exec(ctx, + `INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, 5.0)`, + f.user, seed.ArtistID); err != nil { + t.Fatalf("insert taste (positive): %v", err) + } + + // A second artist with a NEGATIVE weight — its track must be excluded. + negArtist, _ := f.q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "NegArtist", SortName: "NegArtist"}) + negAlbum, _ := f.q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: "NegAlbum", SortTitle: "NegAlbum", ArtistID: negArtist.ID}) + negTrack, _ := f.q.UpsertTrack(ctx, dbq.UpsertTrackParams{ + Title: "NegTrack", AlbumID: negAlbum.ID, ArtistID: negArtist.ID, + FilePath: "/tmp/neg.flac", DurationMs: 180_000, + }) + if _, err := f.pool.Exec(ctx, + `INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, -3.0)`, + f.user, negArtist.ID); err != nil { + t.Fatalf("insert taste (negative): %v", err) + } + + // Only the taste_overlap arm is enabled. + limits := CandidateSourceLimits{TasteOverlap: 10} + got, err := LoadCandidatesFromSimilarity( + ctx, f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, limits, + ) + if err != nil { + t.Fatalf("load: %v", err) + } + var sawTarget, sawNeg bool + for _, c := range got { + switch c.Track.ID { + case target.ID: + sawTarget = true + case negTrack.ID: + sawNeg = true + } + } + if !sawTarget { + t.Error("positive-taste-artist track missing (taste_overlap arm didn't contribute)") + } + if sawNeg { + t.Error("negative-taste-artist track present (weight > 0 filter failed)") + } +} + func TestLoadCandidatesFromSimilarity_EmptyLibrary_NoError(t *testing.T) { f := newFixture(t, 1) // just the seed seed := f.tracks[0]