The same rewrite as d191a282, for four packages whose comments carried the
most history: tuning stories, quotes from the operator, single-install
measurements in library_scale.go, and 'used to' accounts of bugs. The
constraint each story protected stays as a present-tense reason (why tier 3
is reduced not removed, why the randomised arms take an order seed, why the
seed artist is eligible in Songs-like). Comments naming system_cron.go and
config.RecommendationConfig, neither of which holds what they claimed, now
name the scheduler and internal/recsettings.
Checked: non-comment tokens identical to HEAD; gofmt clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
581 lines
20 KiB
Go
581 lines
20 KiB
Go
package recommendation
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"maps"
|
||
"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")
|
||
}
|
||
// Tier 1's band: 0.5 + 0.5 × 0.85.
|
||
if found.Inputs.SimilarityScore < 0.92 || found.Inputs.SimilarityScore > 0.93 {
|
||
t.Errorf("LB-similar SimilarityScore = %v, want ~0.925 (0.5 + 0.5 × 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 → 1/2, in tier 2's band → 0.25.
|
||
if c.Inputs.SimilarityScore < 0.24 || c.Inputs.SimilarityScore > 0.26 {
|
||
t.Errorf("tag-overlap SimilarityScore = %v, want ~0.25 (0.5 × 1/2)", 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" → 0.5 × 1/1 = 0.5 from tag-overlap.
|
||
// likes-overlap = 0.3. Max wins = 0.5.
|
||
if c.Inputs.SimilarityScore < 0.29 {
|
||
t.Errorf("likes-overlap candidate SimilarityScore = %v, want ≥ 0.3", 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") // 0.5 × 1/1 = 0.5 from tag-overlap
|
||
helperLBSimilarity(t, f, seed.ID, target.ID, 0.1) // a weak LB edge → 0.5 + 0.05
|
||
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++
|
||
// max() keeps LB's 0.55. Even a weak ListenBrainz edge is tier 1 and
|
||
// outranks a full tag match.
|
||
if c.Inputs.SimilarityScore < 0.54 || c.Inputs.SimilarityScore > 0.56 {
|
||
t.Errorf("dedup max SimilarityScore = %v, want ~0.55 (the LB edge's band should win)", c.Inputs.SimilarityScore)
|
||
}
|
||
}
|
||
}
|
||
if count != 1 {
|
||
t.Errorf("target appeared %d times, want 1 (dedup failed)", count)
|
||
}
|
||
}
|
||
|
||
// TestLoadCandidatesFromSimilarity_TasteOverlapArm: 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.
|
||
//
|
||
// An arm ordered by bare `random()` returns a stable set only while its LIMIT
|
||
// exceeds the rows eligible for it (it returns all of them, and the caller
|
||
// sorts by track id). Below that it returns a random SUBSET, so two calls
|
||
// draw different candidates — invisible, because a mix that changes after a
|
||
// refresh looks like a feature.
|
||
//
|
||
// The limits are deliberately smaller than the fixture: with limits above the
|
||
// eligible count, an unseeded arm 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))
|
||
}
|
||
}
|
||
|
||
// 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
|
||
// would fill the whole LIMIT.
|
||
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)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// The taste arm takes one track from each taste artist before a second from
|
||
// any. Ordered by weight then track id, the strongest artist's catalogue would
|
||
// fill the arm.
|
||
func TestLoadCandidatesFromSimilarity_TasteArmRoundRobinsAcrossArtists(t *testing.T) {
|
||
f := newFixture(t, 1)
|
||
ctx := context.Background()
|
||
seed := f.tracks[0]
|
||
artistOf := map[[16]byte]string{}
|
||
for i, weight := range []float64{9, 8, 7} {
|
||
name := fmt.Sprintf("Taste %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.
|
||
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/taste-%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 taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, $3)`,
|
||
f.user, ar.ID, weight); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
got, err := LoadCandidatesFromSimilarity(
|
||
ctx, f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, CandidateSourceLimits{TasteOverlap: 3}, "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("taste arm of 3 drew %d candidates from %d artists (%v), want one from each of 3",
|
||
len(got), len(artists), artists)
|
||
}
|
||
}
|
||
|
||
// Tracks tied on tag overlap are taken in the order seed's order: the same
|
||
// seed takes the same slice, another seed another. With no tie-break a
|
||
// one-tag seed's LIMIT would take whichever rows Postgres reached first.
|
||
func TestLoadCandidatesFromSimilarity_TagTiesFollowTheOrderSeed(t *testing.T) {
|
||
f := newFixture(t, 21)
|
||
ctx := context.Background()
|
||
for _, tr := range f.tracks {
|
||
helperSetTrackGenre(t, f, tr.ID, "Rock")
|
||
}
|
||
pick := func(orderSeed string) map[[16]byte]bool {
|
||
got, err := LoadCandidatesFromSimilarity(
|
||
ctx, f.q, f.user, f.tracks[0].ID, 1, SessionVector{Seed: true}, nil, CandidateSourceLimits{TagOverlap: 5}, orderSeed,
|
||
)
|
||
if err != nil {
|
||
t.Fatalf("load: %v", err)
|
||
}
|
||
out := map[[16]byte]bool{}
|
||
for _, c := range got {
|
||
out[c.Track.ID.Bytes] = true
|
||
}
|
||
return out
|
||
}
|
||
a, again, b := pick("day-a"), pick("day-a"), pick("day-b")
|
||
if len(a) != 5 {
|
||
t.Fatalf("tag arm of 5 drew %d", len(a))
|
||
}
|
||
if !maps.Equal(a, again) {
|
||
t.Error("the same order seed took a different slice of tied tracks")
|
||
}
|
||
if maps.Equal(a, b) {
|
||
t.Error("two order seeds took the same slice of 20 tied tracks: the ties are not following the seed")
|
||
}
|
||
}
|