Recommendation relevance, the rollback unit, and a version that names what shipped #131
@@ -703,14 +703,26 @@ func produceSeedMixes(
|
|||||||
"artist_id", uuidStringPL(artistID), "err", cerr)
|
"artist_id", uuidStringPL(artistID), "err", cerr)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// "Songs like X" excludes X's own songs.
|
// The seed artist's own songs are ELIGIBLE here, deliberately.
|
||||||
filtered := make([]recommendation.Candidate, 0, len(cands))
|
//
|
||||||
for _, c := range cands {
|
// This used to filter them out — "Songs like X excludes X's own
|
||||||
if !pgtypeUUIDEqual(c.Track.ArtistID, artistID) {
|
// songs" — which reads as obviously right and is not. The seed is a
|
||||||
filtered = append(filtered, c)
|
// TRACK, and the tracks most likely to sound like it are usually the
|
||||||
}
|
// rest of that artist's catalogue; excluding them threw away the
|
||||||
}
|
// nearest neighbours of the very thing the mix is built around, and
|
||||||
tracks := pickTopN(filtered, userID, dateStr, now, systemMixLength)
|
// then reached further out to replace them. On a surface whose whole
|
||||||
|
// job is staying in one neighbourhood, that is backwards.
|
||||||
|
//
|
||||||
|
// Operator, 2026-09-10: "it should also be able to include music from
|
||||||
|
// the same artist."
|
||||||
|
//
|
||||||
|
// Domination is bounded by the cap rather than by exclusion, which is
|
||||||
|
// the distinction that makes this safe: capCandidatesByAlbumAndArtist
|
||||||
|
// inside pickTopN allows at most discoverMaxTracksPerArtist (3) of a
|
||||||
|
// 25-track mix — 12%, a presence rather than a takeover. The seed
|
||||||
|
// track itself still cannot appear; it is passed as an exclusion to
|
||||||
|
// LoadCandidatesFromSimilarity above.
|
||||||
|
tracks := pickTopN(cands, userID, dateStr, now, systemMixLength)
|
||||||
if len(tracks) == 0 {
|
if len(tracks) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -287,6 +287,79 @@ func TestBuildSystemPlaylists_Concurrency(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The seed artist's own tracks are ELIGIBLE for its "Songs like" mix, and are
|
||||||
|
// bounded by the diversity cap rather than excluded outright (#3881).
|
||||||
|
//
|
||||||
|
// produceSeedMixes used to filter them out — "Songs like X excludes X's own
|
||||||
|
// songs" — which threw away the nearest neighbours of the seed TRACK and then
|
||||||
|
// reached further out to replace them. Operator, 2026-09-10: "it should also
|
||||||
|
// be able to include music from the same artist."
|
||||||
|
//
|
||||||
|
// Asserted end-to-end rather than by reading the source, because the guard has
|
||||||
|
// to survive the filter coming back in a different shape — and because an
|
||||||
|
// absence check would now match the comment explaining why the filter is gone.
|
||||||
|
func TestBuildSystemPlaylists_SongsLikeIncludesItsSeedArtist(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
logger := discardLogger()
|
||||||
|
u, _ := seedActiveLibrary(t, pool, "seedartist", 4, 5)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
if err := playlists.BuildSystemPlaylists(ctx, pool, logger, u.ID, now, t.TempDir()); err != nil {
|
||||||
|
t.Fatalf("build: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := pool.Query(ctx, `
|
||||||
|
SELECT count(*) FILTER (WHERE t.artist_id = p.seed_artist_id) AS own,
|
||||||
|
count(*) AS total
|
||||||
|
FROM playlists p
|
||||||
|
JOIN playlist_tracks pt ON pt.playlist_id = p.id
|
||||||
|
JOIN tracks t ON t.id = pt.track_id
|
||||||
|
WHERE p.user_id = $1 AND p.system_variant = 'songs_like_artist'
|
||||||
|
GROUP BY p.id
|
||||||
|
`, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query: %v", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
// discoverMaxTracksPerArtist, which this package_test cannot reference.
|
||||||
|
// Duplicated deliberately: if the cap moves, this failing is the point.
|
||||||
|
const maxPerArtist = 3
|
||||||
|
|
||||||
|
mixes, withOwn := 0, 0
|
||||||
|
for rows.Next() {
|
||||||
|
var own, total int
|
||||||
|
if err := rows.Scan(&own, &total); err != nil {
|
||||||
|
t.Fatalf("scan: %v", err)
|
||||||
|
}
|
||||||
|
mixes++
|
||||||
|
if own > 0 {
|
||||||
|
withOwn++
|
||||||
|
}
|
||||||
|
// The bound is what makes inclusion safe. Without it, "include the
|
||||||
|
// seed artist" becomes "the mix is mostly the seed artist", which is
|
||||||
|
// the radio failure (#3882) arriving on a different surface.
|
||||||
|
if own > maxPerArtist {
|
||||||
|
t.Errorf("a songs_like mix carries %d tracks by its own seed artist "+
|
||||||
|
"out of %d; the per-artist cap (%d) is not being applied",
|
||||||
|
own, total, maxPerArtist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
t.Fatalf("rows: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if mixes == 0 {
|
||||||
|
t.Fatal("no songs_like_artist mixes were built, so this test asserts nothing")
|
||||||
|
}
|
||||||
|
if withOwn == 0 {
|
||||||
|
t.Errorf("none of the %d songs_like mixes contains a single track by its own "+
|
||||||
|
"seed artist — the tracks most likely to sound like the seed are being "+
|
||||||
|
"excluded from the surface whose job is sounding like the seed", mixes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildSystemPlaylists_DailyNonceDeterminism(t *testing.T) {
|
func TestBuildSystemPlaylists_DailyNonceDeterminism(t *testing.T) {
|
||||||
pool := newPool(t)
|
pool := newPool(t)
|
||||||
logger := discardLogger()
|
logger := discardLogger()
|
||||||
|
|||||||
Reference in New Issue
Block a user