feat(taste): phase 2b — taste_overlap candidate arm (#796)
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:
@@ -737,6 +737,17 @@ likes_overlap AS (
|
|||||||
ORDER BY random()
|
ORDER BY random()
|
||||||
LIMIT $8
|
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 (
|
random_fill AS (
|
||||||
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
@@ -747,6 +758,7 @@ random_fill AS (
|
|||||||
UNION SELECT track_id FROM similar_artists
|
UNION SELECT track_id FROM similar_artists
|
||||||
UNION SELECT track_id FROM tag_overlap
|
UNION SELECT track_id FROM tag_overlap
|
||||||
UNION SELECT track_id FROM likes_overlap
|
UNION SELECT track_id FROM likes_overlap
|
||||||
|
UNION SELECT track_id FROM taste_overlap
|
||||||
)
|
)
|
||||||
ORDER BY random()
|
ORDER BY random()
|
||||||
LIMIT $9
|
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 similar_artists
|
||||||
UNION ALL SELECT track_id, sim_score FROM tag_overlap
|
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 likes_overlap
|
||||||
|
UNION ALL SELECT track_id, sim_score FROM taste_overlap
|
||||||
UNION ALL SELECT track_id, sim_score FROM random_fill
|
UNION ALL SELECT track_id, sim_score FROM random_fill
|
||||||
) u
|
) u
|
||||||
JOIN tracks t ON t.id = u.track_id
|
JOIN tracks t ON t.id = u.track_id
|
||||||
@@ -790,6 +803,7 @@ type LoadRadioCandidatesV2Params struct {
|
|||||||
Limit_3 int32
|
Limit_3 int32
|
||||||
Limit_4 int32
|
Limit_4 int32
|
||||||
Limit_5 int32
|
Limit_5 int32
|
||||||
|
Limit_6 int32
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoadRadioCandidatesV2Row struct {
|
type LoadRadioCandidatesV2Row struct {
|
||||||
@@ -801,11 +815,15 @@ type LoadRadioCandidatesV2Row struct {
|
|||||||
SimilarityScore interface{}
|
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,
|
// $1 user_id, $2 seed_track_id, $3 recently_played_hours,
|
||||||
// $4 exclude (uuid[]), $5 lb_similar K, $6 similar_artists K,
|
// $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.
|
// Returns same shape as LoadRadioCandidates plus similarity_score column.
|
||||||
func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error) {
|
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_3,
|
||||||
arg.Limit_4,
|
arg.Limit_4,
|
||||||
arg.Limit_5,
|
arg.Limit_5,
|
||||||
|
arg.Limit_6,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -33,10 +33,14 @@ WHERE t.id <> $2
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- name: LoadRadioCandidatesV2 :many
|
-- 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,
|
-- $1 user_id, $2 seed_track_id, $3 recently_played_hours,
|
||||||
-- $4 exclude (uuid[]), $5 lb_similar K, $6 similar_artists K,
|
-- $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.
|
-- Returns same shape as LoadRadioCandidates plus similarity_score column.
|
||||||
|
|
||||||
WITH
|
WITH
|
||||||
@@ -109,6 +113,17 @@ likes_overlap AS (
|
|||||||
ORDER BY random()
|
ORDER BY random()
|
||||||
LIMIT $8
|
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 (
|
random_fill AS (
|
||||||
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
@@ -119,6 +134,7 @@ random_fill AS (
|
|||||||
UNION SELECT track_id FROM similar_artists
|
UNION SELECT track_id FROM similar_artists
|
||||||
UNION SELECT track_id FROM tag_overlap
|
UNION SELECT track_id FROM tag_overlap
|
||||||
UNION SELECT track_id FROM likes_overlap
|
UNION SELECT track_id FROM likes_overlap
|
||||||
|
UNION SELECT track_id FROM taste_overlap
|
||||||
)
|
)
|
||||||
ORDER BY random()
|
ORDER BY random()
|
||||||
LIMIT $9
|
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 similar_artists
|
||||||
UNION ALL SELECT track_id, sim_score FROM tag_overlap
|
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 likes_overlap
|
||||||
|
UNION ALL SELECT track_id, sim_score FROM taste_overlap
|
||||||
UNION ALL SELECT track_id, sim_score FROM random_fill
|
UNION ALL SELECT track_id, sim_score FROM random_fill
|
||||||
) u
|
) u
|
||||||
JOIN tracks t ON t.id = u.track_id
|
JOIN tracks t ON t.id = u.track_id
|
||||||
|
|||||||
@@ -329,6 +329,10 @@ func systemForYouSourceLimits() recommendation.CandidateSourceLimits {
|
|||||||
TagOverlap: 60,
|
TagOverlap: 60,
|
||||||
LikesOverlap: 40,
|
LikesOverlap: 40,
|
||||||
RandomFill: 150,
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,9 +89,15 @@ func buildYouMightLike(
|
|||||||
}
|
}
|
||||||
|
|
||||||
zeroVec := recommendation.SessionVector{Seed: true}
|
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(
|
cands, err := recommendation.LoadCandidatesFromSimilarity(
|
||||||
ctx, q, userID, seed, 1, zeroVec,
|
ctx, q, userID, seed, 1, zeroVec,
|
||||||
[]pgtype.UUID{seed}, systemForYouSourceLimits(),
|
[]pgtype.UUID{seed}, ymlLimits,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("you-might-like: candidate load failed; skipping",
|
logger.Warn("you-might-like: candidate load failed; skipping",
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ type CandidateSourceLimits struct {
|
|||||||
TagOverlap int
|
TagOverlap int
|
||||||
LikesOverlap int
|
LikesOverlap int
|
||||||
RandomFill 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.
|
// DefaultCandidateSourceLimits returns the v1 hardcoded constants per spec.
|
||||||
@@ -82,6 +86,7 @@ func DefaultCandidateSourceLimits() CandidateSourceLimits {
|
|||||||
TagOverlap: 20,
|
TagOverlap: 20,
|
||||||
LikesOverlap: 20,
|
LikesOverlap: 20,
|
||||||
RandomFill: 30,
|
RandomFill: 30,
|
||||||
|
TasteOverlap: 20,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +118,7 @@ func LoadCandidatesFromSimilarity(
|
|||||||
Limit_3: int32(limits.TagOverlap),
|
Limit_3: int32(limits.TagOverlap),
|
||||||
Limit_4: int32(limits.LikesOverlap),
|
Limit_4: int32(limits.LikesOverlap),
|
||||||
Limit_5: int32(limits.RandomFill),
|
Limit_5: int32(limits.RandomFill),
|
||||||
|
Limit_6: int32(limits.TasteOverlap),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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) {
|
func TestLoadCandidatesFromSimilarity_EmptyLibrary_NoError(t *testing.T) {
|
||||||
f := newFixture(t, 1) // just the seed
|
f := newFixture(t, 1) // just the seed
|
||||||
seed := f.tracks[0]
|
seed := f.tracks[0]
|
||||||
|
|||||||
Reference in New Issue
Block a user