Files
minstrel/internal/recommendation/candidates_v2_test.go
T
bvandeusenandClaude Opus 5.5 aa390c711a fix(recommendation): artist-level arms take one track per related artist in turn (#5297)
The similar_artists and coplay_artists arms ordered by artist score, so the
closest related artist's catalogue filled the whole LIMIT. On the operator's
library the 30-row similar_artists arm held exactly one artist for all 17
seeds measured (#3879), though each seed had 7-33 similar artists in the
library. Both arms now rank tracks within each artist and take every
artist's first track, best artist first, before anyone's second.

Both also skip missing tracks: the outer select already dropped them, but
only after they had taken places in the arm's LIMIT.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:56:21 -04:00

498 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package recommendation
import (
"context"
"fmt"
"reflect"
"sort"
"strings"
"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(), "test-seed",
)
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(), "test-seed",
)
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(), "test-seed",
)
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(), "test-seed",
)
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(), "test-seed",
)
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(), "test-seed",
)
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(), "test-seed",
)
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(), "test-seed",
)
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(), "test-seed",
)
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, "test-seed",
)
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(), "test-seed",
)
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))
}
}
// The randomised arms must draw REPRODUCIBLY for a given seed (#3889).
//
// Four arms used to end in a bare `ORDER BY random()`. That returned a stable
// set only while the arm's LIMIT exceeded the rows eligible for it — at that
// point it returned all of them and the order stopped mattering, because the
// caller sorts by track id before scoring. Below that threshold it returned a
// random SUBSET, so two calls drew different candidates.
//
// It therefore held by ACCIDENT, and only for libraries smaller than the
// limits. Any real library is larger, so same-day rebuilds had been drawing
// different mixes since the arm was written — invisible, because a mix that
// changes after a refresh looks like a feature.
//
// Limits deliberately smaller than the fixture, because that is the only
// regime where the bug existed at all: with limits above the eligible count
// the old code passes this too.
func TestLoadCandidatesFromSimilarity_SameSeedDrawsTheSameSet(t *testing.T) {
f := newFixture(t, 12)
seed := f.tracks[0]
tight := CandidateSourceLimits{
LBSimilar: 2, SimilarArtist: 2, TagOverlap: 2,
LikesOverlap: 2, RandomFill: 3, TasteOverlap: 2, UserCoplay: 2,
}
ids := func(cs []Candidate) []string {
out := make([]string, 0, len(cs))
for _, c := range cs {
out = append(out, fmt.Sprintf("%x", c.Track.ID.Bytes))
}
sort.Strings(out) // membership, not order — order is settled downstream
return out
}
first, err := LoadCandidatesFromSimilarity(
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, tight, "day-one",
)
if err != nil {
t.Fatalf("load: %v", err)
}
if len(first) == 0 {
t.Fatal("no candidates, so this test asserts nothing")
}
for i := 0; i < 3; i++ {
again, err := LoadCandidatesFromSimilarity(
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, tight, "day-one",
)
if err != nil {
t.Fatalf("load %d: %v", i, err)
}
if !reflect.DeepEqual(ids(first), ids(again)) {
t.Fatalf("same seed drew a different set on call %d:\n first %v\n again %v",
i, ids(first), ids(again))
}
}
}
// ...and a different seed is free to draw differently, or the ordering would
// be fixed rather than seeded and every day would serve the same mix.
//
// Asserted as "not pinned to one answer" rather than "always differs": with a
// small fixture two seeds can legitimately collide, so requiring a difference
// on any single pair would be flaky. Several seeds producing exactly one
// distinct set is the real regression — that is what a constant ORDER BY
// looks like.
func TestLoadCandidatesFromSimilarity_DifferentSeedsCanDrawDifferently(t *testing.T) {
f := newFixture(t, 12)
seed := f.tracks[0]
tight := CandidateSourceLimits{
LBSimilar: 2, SimilarArtist: 2, TagOverlap: 2,
LikesOverlap: 2, RandomFill: 3, TasteOverlap: 2, UserCoplay: 2,
}
seen := map[string]bool{}
for _, orderSeed := range []string{"a", "b", "c", "d", "e", "f"} {
cs, err := LoadCandidatesFromSimilarity(
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, tight, orderSeed,
)
if err != nil {
t.Fatalf("load %q: %v", orderSeed, err)
}
ids := make([]string, 0, len(cs))
for _, c := range cs {
ids = append(ids, fmt.Sprintf("%x", c.Track.ID.Bytes))
}
sort.Strings(ids)
seen[strings.Join(ids, ",")] = true
}
if len(seen) < 2 {
t.Errorf("six different seeds produced %d distinct set(s); the ordering is not "+
"varying with the seed at all", len(seen))
}
}
// #5297: an artist-level arm takes a track from each related artist before a
// second from any. Ordered by artist score alone, the closest artist's
// catalogue filled the whole LIMIT; on the operator's library the
// similar_artists arm held one artist for every seed measured (#3879).
func TestLoadCandidatesFromSimilarity_ArtistArmsRoundRobin(t *testing.T) {
for _, source := range []string{"listenbrainz", "user_cooccurrence"} {
t.Run(source, func(t *testing.T) {
f := newFixture(t, 1)
ctx := context.Background()
seed := f.tracks[0]
artistOf := map[[16]byte]string{}
for i, score := range []float64{0.9, 0.8, 0.7} {
name := fmt.Sprintf("Related %d", i)
ar, err := f.q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: name, SortName: name})
if err != nil {
t.Fatal(err)
}
al, err := f.q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: name, SortTitle: name, ArtistID: ar.ID})
if err != nil {
t.Fatal(err)
}
// More tracks per artist than the arm's limit, so the closest
// artist alone could fill it.
for j := 0; j < 5; j++ {
tr, err := f.q.UpsertTrack(ctx, dbq.UpsertTrackParams{
Title: fmt.Sprintf("%s #%d", name, j), AlbumID: al.ID, ArtistID: ar.ID,
FilePath: fmt.Sprintf("/tmp/related-%d-%d.flac", i, j), DurationMs: 180_000,
})
if err != nil {
t.Fatal(err)
}
artistOf[tr.ID.Bytes] = name
}
if _, err := f.pool.Exec(ctx,
`INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source) VALUES ($1, $2, $3, $4)`,
seed.ArtistID, ar.ID, score, source); err != nil {
t.Fatal(err)
}
}
limits := CandidateSourceLimits{}
if source == "listenbrainz" {
limits.SimilarArtist = 3
} else {
limits.UserCoplay = 3
}
got, err := LoadCandidatesFromSimilarity(
ctx, f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, limits, "test-seed",
)
if err != nil {
t.Fatalf("load: %v", err)
}
artists := map[string]int{}
for _, c := range got {
if name, ok := artistOf[c.Track.ID.Bytes]; ok {
artists[name]++
}
}
if len(got) != 3 || len(artists) != 3 {
t.Errorf("arm of 3 drew %d candidates from %d related artists (%v), want one from each of 3",
len(got), len(artists), artists)
}
})
}
}