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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user