Recommendation relevance, the rollback unit, and a version that names what shipped #131

Merged
bvandeusen merged 11 commits from dev into main 2026-09-10 23:37:57 -04:00
2 changed files with 93 additions and 8 deletions
Showing only changes of commit 31190657d8 - Show all commits
+20 -8
View File
@@ -703,14 +703,26 @@ func produceSeedMixes(
"artist_id", uuidStringPL(artistID), "err", cerr)
continue
}
// "Songs like X" excludes X's own songs.
filtered := make([]recommendation.Candidate, 0, len(cands))
for _, c := range cands {
if !pgtypeUUIDEqual(c.Track.ArtistID, artistID) {
filtered = append(filtered, c)
}
}
tracks := pickTopN(filtered, userID, dateStr, now, systemMixLength)
// The seed artist's own songs are ELIGIBLE here, deliberately.
//
// This used to filter them out — "Songs like X excludes X's own
// songs" — which reads as obviously right and is not. The seed is a
// 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
// 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 {
continue
}
+73
View File
@@ -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) {
pool := newPool(t)
logger := discardLogger()