feat(taste): phase 2b — taste_overlap candidate arm (#796)
test-go / test (push) Successful in 37s
test-go / integration (push) Successful in 4m41s

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 00:05:50 -04:00
parent c7adf2c87a
commit 6c26ba807e
6 changed files with 112 additions and 5 deletions
+21 -2
View File
@@ -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
+19 -2
View File
@@ -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
+4
View File
@@ -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,
}
}
+7 -1
View File
@@ -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",
+6
View File
@@ -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
@@ -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]