Files
minstrel/internal/recommendation/candidates_v2_test.go
T
bvandeusenandClaude Opus 5.5 5bcbc80555
release / govulncheck (push) Successful in 31s
release / web (push) Successful in 1m37s
release / go (push) Successful in 1m46s
release / integration (push) Successful in 5m20s
release / android (push) Successful in 5m22s
release / Build signed APK (releases and dev) (push) Successful in 5m39s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m29s
release / Verify release artifacts (tag releases only) (push) Skipped
fix: the taste arm round-robins across taste artists, and tag ties follow the order seed (#3880)
Measured on the operator's library (26,005 tracks, pool scale 2.28): the
arms the library scale grows all have far more rows than it asks for, and
two arms collapsed whatever their limits were.

- taste_overlap ordered by weight then track id, so the strongest taste
  artist's catalogue filled it: 20 tracks of saib. (197 tracks) out of 174
  taste artists, the same lowest-id tracks every time. It now takes one
  track per taste artist, strongest first, md5-seeded, as similar_artists
  does since #5297.
- tag_overlap broke ties in no order. A one-tag seed ties 7,804 tracks,
  and the LIMIT took whichever Postgres reached first, which the order
  seed did not control. Ties now follow md5(id || seed).
- library_scale.go records the per-arm measurement beside the reference
  size it leaves as is.

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

588 lines
20 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"
"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 (#6177): 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 (#6177).
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 (#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)
}
})
}
}
// The taste arm takes one track from each taste artist before a second from
// any (#3880). Ordered by weight then track id, the strongest artist's
// catalogue filled the arm: 20 tracks of one artist on the operator's library.
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 (#3880): the
// same seed takes the same slice, another seed another. With no tie-break a
// one-tag seed's LIMIT took 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")
}
}