6c26ba807e
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>
334 lines
11 KiB
Go
334 lines
11 KiB
Go
package recommendation
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
|
||
"github.com/jackc/pgx/v5/pgtype"
|
||
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||
)
|
||
|
||
// helperLBSimilarity inserts a track_similarity row.
|
||
func helperLBSimilarity(t *testing.T, f fixture, a, b pgtype.UUID, score float64) {
|
||
t.Helper()
|
||
if _, err := f.pool.Exec(context.Background(),
|
||
`INSERT INTO track_similarity (track_a_id, track_b_id, score, source) VALUES ($1, $2, $3, 'listenbrainz')`,
|
||
a, b, score); err != nil {
|
||
t.Fatalf("insert track_similarity: %v", err)
|
||
}
|
||
}
|
||
|
||
// helperArtistSimilarity inserts an artist_similarity row.
|
||
func helperArtistSimilarity(t *testing.T, f fixture, a, b pgtype.UUID, score float64) {
|
||
t.Helper()
|
||
if _, err := f.pool.Exec(context.Background(),
|
||
`INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source) VALUES ($1, $2, $3, 'listenbrainz')`,
|
||
a, b, score); err != nil {
|
||
t.Fatalf("insert artist_similarity: %v", err)
|
||
}
|
||
}
|
||
|
||
// helperSetTrackGenre updates a track's genre column. Used to retrofit
|
||
// genres onto the fixture's auto-created tracks (fixture creates tracks
|
||
// with NULL genre).
|
||
func helperSetTrackGenre(t *testing.T, f fixture, trackID pgtype.UUID, genre string) {
|
||
t.Helper()
|
||
if _, err := f.pool.Exec(context.Background(),
|
||
`UPDATE tracks SET genre = $1 WHERE id = $2`, genre, trackID); err != nil {
|
||
t.Fatalf("set genre: %v", err)
|
||
}
|
||
}
|
||
|
||
func defaultLimits() CandidateSourceLimits {
|
||
return DefaultCandidateSourceLimits()
|
||
}
|
||
|
||
func TestLoadCandidatesFromSimilarity_LBSimilarSourceContributes(t *testing.T) {
|
||
f := newFixture(t, 5)
|
||
seed := f.tracks[0]
|
||
target := f.tracks[1]
|
||
helperLBSimilarity(t, f, seed.ID, target.ID, 0.85)
|
||
got, err := LoadCandidatesFromSimilarity(
|
||
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
|
||
)
|
||
if err != nil {
|
||
t.Fatalf("load: %v", err)
|
||
}
|
||
var found *Candidate
|
||
for i := range got {
|
||
if got[i].Track.ID == target.ID {
|
||
found = &got[i]
|
||
break
|
||
}
|
||
}
|
||
if found == nil {
|
||
t.Fatal("LB-similar target missing from candidates")
|
||
}
|
||
if found.Inputs.SimilarityScore < 0.84 || found.Inputs.SimilarityScore > 0.86 {
|
||
t.Errorf("LB-similar SimilarityScore = %v, want ~0.85", found.Inputs.SimilarityScore)
|
||
}
|
||
}
|
||
|
||
func TestLoadCandidatesFromSimilarity_SimilarArtistTracksContribute(t *testing.T) {
|
||
f := newFixture(t, 1) // creates 1 artist + 1 album + 1 track (the seed)
|
||
seed := f.tracks[0]
|
||
// Add a SECOND artist + track in that artist; relate the two artists via artist_similarity.
|
||
otherArtist, _ := f.q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "OtherArtist", SortName: "OtherArtist"})
|
||
otherAlbum, _ := f.q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "OtherAlbum", SortTitle: "OtherAlbum", ArtistID: otherArtist.ID})
|
||
otherTrack, _ := f.q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
|
||
Title: "OtherTrack", AlbumID: otherAlbum.ID, ArtistID: otherArtist.ID,
|
||
FilePath: "/tmp/other.flac", DurationMs: 180_000,
|
||
})
|
||
helperArtistSimilarity(t, f, seed.ArtistID, otherArtist.ID, 0.8)
|
||
got, err := LoadCandidatesFromSimilarity(
|
||
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
|
||
)
|
||
if err != nil {
|
||
t.Fatalf("load: %v", err)
|
||
}
|
||
for _, c := range got {
|
||
if c.Track.ID == otherTrack.ID {
|
||
// 0.8 × 0.5 = 0.4
|
||
if c.Inputs.SimilarityScore < 0.39 || c.Inputs.SimilarityScore > 0.41 {
|
||
t.Errorf("similar-artist SimilarityScore = %v, want ~0.4 (0.8 × 0.5)", c.Inputs.SimilarityScore)
|
||
}
|
||
return
|
||
}
|
||
}
|
||
t.Error("similar-artist track missing from candidates")
|
||
}
|
||
|
||
func TestLoadCandidatesFromSimilarity_TagOverlapContributes(t *testing.T) {
|
||
f := newFixture(t, 2)
|
||
seed := f.tracks[0]
|
||
target := f.tracks[1]
|
||
helperSetTrackGenre(t, f, seed.ID, "Rock; Pop")
|
||
helperSetTrackGenre(t, f, target.ID, "Rock")
|
||
got, err := LoadCandidatesFromSimilarity(
|
||
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
|
||
)
|
||
if err != nil {
|
||
t.Fatalf("load: %v", err)
|
||
}
|
||
for _, c := range got {
|
||
if c.Track.ID == target.ID {
|
||
// Seed has 2 tags; target shares 1 → jaccard 1/2 = 0.5.
|
||
if c.Inputs.SimilarityScore < 0.49 || c.Inputs.SimilarityScore > 0.51 {
|
||
t.Errorf("tag-overlap SimilarityScore = %v, want ~0.5", c.Inputs.SimilarityScore)
|
||
}
|
||
return
|
||
}
|
||
}
|
||
t.Error("tag-overlap target missing from candidates")
|
||
}
|
||
|
||
func TestLoadCandidatesFromSimilarity_LikesOverlapContributes(t *testing.T) {
|
||
f := newFixture(t, 2)
|
||
seed := f.tracks[0]
|
||
liked := f.tracks[1]
|
||
helperSetTrackGenre(t, f, seed.ID, "Rock")
|
||
helperSetTrackGenre(t, f, liked.ID, "Rock")
|
||
if _, err := f.q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: f.user, TrackID: liked.ID}); err != nil {
|
||
t.Fatalf("like: %v", err)
|
||
}
|
||
got, err := LoadCandidatesFromSimilarity(
|
||
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
|
||
)
|
||
if err != nil {
|
||
t.Fatalf("load: %v", err)
|
||
}
|
||
for _, c := range got {
|
||
if c.Track.ID == liked.ID {
|
||
// Both tracks tagged "Rock" → jaccard 1/1 = 1.0 from tag-overlap.
|
||
// likes-overlap = 0.6. Max wins = 1.0.
|
||
if c.Inputs.SimilarityScore < 0.59 {
|
||
t.Errorf("likes-overlap candidate SimilarityScore = %v, want ≥ 0.6", c.Inputs.SimilarityScore)
|
||
}
|
||
return
|
||
}
|
||
}
|
||
t.Error("liked track with shared tag missing from candidates")
|
||
}
|
||
|
||
func TestLoadCandidatesFromSimilarity_RandomFillReturnsTracks(t *testing.T) {
|
||
f := newFixture(t, 10) // 10 tracks; no similarity data
|
||
seed := f.tracks[0]
|
||
got, err := LoadCandidatesFromSimilarity(
|
||
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
|
||
)
|
||
if err != nil {
|
||
t.Fatalf("load: %v", err)
|
||
}
|
||
if len(got) == 0 {
|
||
t.Error("random fill returned 0 candidates; expected at least some")
|
||
}
|
||
for _, c := range got {
|
||
if c.Inputs.SimilarityScore != 0 {
|
||
t.Errorf("random-fill track %s has SimilarityScore = %v, want 0", c.Track.Title, c.Inputs.SimilarityScore)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestLoadCandidatesFromSimilarity_ExcludeListRespected(t *testing.T) {
|
||
f := newFixture(t, 5)
|
||
seed := f.tracks[0]
|
||
excluded := f.tracks[1].ID
|
||
got, err := LoadCandidatesFromSimilarity(
|
||
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true},
|
||
[]pgtype.UUID{excluded}, defaultLimits(),
|
||
)
|
||
if err != nil {
|
||
t.Fatalf("load: %v", err)
|
||
}
|
||
for _, c := range got {
|
||
if c.Track.ID == excluded {
|
||
t.Error("excluded track appeared in candidates")
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestLoadCandidatesFromSimilarity_SeedAlwaysExcluded(t *testing.T) {
|
||
f := newFixture(t, 5)
|
||
seed := f.tracks[0]
|
||
got, err := LoadCandidatesFromSimilarity(
|
||
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
|
||
)
|
||
if err != nil {
|
||
t.Fatalf("load: %v", err)
|
||
}
|
||
for _, c := range got {
|
||
if c.Track.ID == seed.ID {
|
||
t.Error("seed track appeared in candidates")
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestLoadCandidatesFromSimilarity_RecentlyPlayedExcluded(t *testing.T) {
|
||
f := newFixture(t, 5)
|
||
seed := f.tracks[0]
|
||
recent := f.tracks[1].ID
|
||
var sessionID pgtype.UUID
|
||
if err := f.pool.QueryRow(context.Background(),
|
||
`INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id)
|
||
VALUES ($1, now() - interval '5 minutes', now(), 'test') RETURNING id`,
|
||
f.user).Scan(&sessionID); err != nil {
|
||
t.Fatalf("session: %v", err)
|
||
}
|
||
if _, err := f.pool.Exec(context.Background(),
|
||
`INSERT INTO play_events (user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped)
|
||
VALUES ($1, $2, $3, now() - interval '30 minutes', now() - interval '20 minutes', 200000, 0.9, false)`,
|
||
f.user, recent, sessionID); err != nil {
|
||
t.Fatalf("play_event: %v", err)
|
||
}
|
||
got, err := LoadCandidatesFromSimilarity(
|
||
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
|
||
)
|
||
if err != nil {
|
||
t.Fatalf("load: %v", err)
|
||
}
|
||
for _, c := range got {
|
||
if c.Track.ID == recent {
|
||
t.Error("recently-played track appeared in candidates")
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestLoadCandidatesFromSimilarity_DedupTakesMaxScore(t *testing.T) {
|
||
f := newFixture(t, 2)
|
||
seed := f.tracks[0]
|
||
target := f.tracks[1]
|
||
helperSetTrackGenre(t, f, seed.ID, "Rock")
|
||
helperSetTrackGenre(t, f, target.ID, "Rock") // jaccard 1/1 = 1.0 from tag-overlap
|
||
helperLBSimilarity(t, f, seed.ID, target.ID, 0.5) // weaker LB signal
|
||
got, err := LoadCandidatesFromSimilarity(
|
||
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
|
||
)
|
||
if err != nil {
|
||
t.Fatalf("load: %v", err)
|
||
}
|
||
count := 0
|
||
for _, c := range got {
|
||
if c.Track.ID == target.ID {
|
||
count++
|
||
// tag-overlap (1.0) wins over LB (0.5) per max() — expect ≥ 0.99
|
||
if c.Inputs.SimilarityScore < 0.99 {
|
||
t.Errorf("dedup max SimilarityScore = %v, want ≥ 0.99 (tag-overlap should win)", c.Inputs.SimilarityScore)
|
||
}
|
||
}
|
||
}
|
||
if count != 1 {
|
||
t.Errorf("target appeared %d times, want 1 (dedup failed)", count)
|
||
}
|
||
}
|
||
|
||
// 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]
|
||
got, err := LoadCandidatesFromSimilarity(
|
||
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
|
||
)
|
||
if err != nil {
|
||
t.Fatalf("load: %v", err)
|
||
}
|
||
// Only the seed exists; it's excluded → 0 candidates.
|
||
if len(got) != 0 {
|
||
t.Errorf("got %d candidates from seed-only library, want 0", len(got))
|
||
}
|
||
}
|