fix(playlists): Songs-like mixes no longer vanish after a quiet week
PickSeedArtists had a hard 7-day window with no fallback: a week without listening emptied the seed pool, produceSeedMixes returned zero playlists, and the daily atomic-replace build deleted every existing "Songs like X" mix until the user played something again (#1255). The query now falls back through widening engagement windows — 7d → 30d → all-time → liked artists — the same tiered shape that fixed the identical vanish for For You's seeds (PickTopPlayedTracksForUser). Like-boost scoring is preserved in every tier. All returned rows share the winning tier, and produceSeedMixes maps it onto the rule-#131 pick-kind ladder (7d = tier1 exact, 30d = tier2, all-time/liked = tier3) and stamps the built tracks — the #1270 provenance pipeline then attributes plays and skips to seed freshness, so the metrics card can say whether stale-seeded mixes actually perform worse. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
This commit is contained in:
@@ -290,9 +290,13 @@ func (q *Queries) ListPlaylistsByUserAndKind(ctx context.Context, arg ListPlayli
|
||||
}
|
||||
|
||||
const pickSeedArtists = `-- name: PickSeedArtists :many
|
||||
WITH plays AS (
|
||||
WITH liked AS (
|
||||
SELECT gla.artist_id FROM general_likes_artists gla WHERE gla.user_id = $1
|
||||
),
|
||||
recent7 AS (
|
||||
SELECT t.artist_id,
|
||||
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count
|
||||
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
|
||||
0 AS tier
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
WHERE pe.user_id = $1
|
||||
@@ -300,27 +304,79 @@ WITH plays AS (
|
||||
AND t.artist_id IS NOT NULL
|
||||
GROUP BY t.artist_id
|
||||
),
|
||||
liked AS (
|
||||
SELECT artist_id FROM general_likes_artists WHERE user_id = $1
|
||||
recent30 AS (
|
||||
SELECT t.artist_id,
|
||||
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
|
||||
1 AS tier
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.started_at > now() - INTERVAL '30 days'
|
||||
AND t.artist_id IS NOT NULL
|
||||
GROUP BY t.artist_id
|
||||
),
|
||||
alltime AS (
|
||||
SELECT t.artist_id,
|
||||
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
|
||||
2 AS tier
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
WHERE pe.user_id = $1
|
||||
AND t.artist_id IS NOT NULL
|
||||
GROUP BY t.artist_id
|
||||
),
|
||||
likedonly AS (
|
||||
SELECT l.artist_id, 0::bigint AS play_count, 3 AS tier
|
||||
FROM liked l
|
||||
),
|
||||
chosen AS (
|
||||
SELECT artist_id, play_count, tier FROM recent7
|
||||
UNION ALL
|
||||
SELECT artist_id, play_count, tier FROM recent30
|
||||
WHERE NOT EXISTS (SELECT 1 FROM recent7)
|
||||
UNION ALL
|
||||
SELECT artist_id, play_count, tier FROM alltime
|
||||
WHERE NOT EXISTS (SELECT 1 FROM recent7)
|
||||
AND NOT EXISTS (SELECT 1 FROM recent30)
|
||||
UNION ALL
|
||||
SELECT artist_id, play_count, tier FROM likedonly
|
||||
WHERE NOT EXISTS (SELECT 1 FROM recent7)
|
||||
AND NOT EXISTS (SELECT 1 FROM recent30)
|
||||
AND NOT EXISTS (SELECT 1 FROM alltime)
|
||||
)
|
||||
SELECT p.artist_id,
|
||||
(p.play_count + CASE WHEN l.artist_id IS NOT NULL THEN 5 ELSE 0 END)::bigint AS score
|
||||
FROM plays p
|
||||
LEFT JOIN liked l ON l.artist_id = p.artist_id
|
||||
ORDER BY score DESC, p.artist_id
|
||||
SELECT c.artist_id,
|
||||
(c.play_count + CASE WHEN l.artist_id IS NOT NULL THEN 5 ELSE 0 END)::bigint AS score,
|
||||
c.tier::int AS tier
|
||||
FROM chosen c
|
||||
LEFT JOIN liked l ON l.artist_id = c.artist_id
|
||||
ORDER BY score DESC, c.artist_id
|
||||
LIMIT 5
|
||||
`
|
||||
|
||||
type PickSeedArtistsRow struct {
|
||||
ArtistID pgtype.UUID
|
||||
Score int64
|
||||
Tier int32
|
||||
}
|
||||
|
||||
// Top-5 most-engaged distinct artist candidates in the user's last 7
|
||||
// days. The Go-side picker (pickSeedArtistsForDay) shuffles these
|
||||
// daily-deterministically and takes the first 3 so the set of
|
||||
// "Songs like X" mixes rotates day-to-day.
|
||||
// Score = unskipped-play count + 5 if user has liked the artist.
|
||||
// Top-5 most-engaged distinct artist candidates, tiered so the
|
||||
// "Songs like X" mixes never silently vanish (#1255): the old hard
|
||||
// 7-day window emptied the seed pool after a quiet week, and the
|
||||
// daily atomic-replace build then deleted every existing mix until
|
||||
// the user played something again. Same fallback shape as
|
||||
// PickTopPlayedTracksForUser (For You's seeds):
|
||||
//
|
||||
// tier 0 engagement in the last 7 days
|
||||
// tier 1 (only if tier 0 empty) last 30 days
|
||||
// tier 2 (only if tiers 0+1 empty) all-time
|
||||
// tier 3 (only if 0-2 empty) liked artists with no play history
|
||||
//
|
||||
// All returned rows share one tier; produceSeedMixes maps it onto the
|
||||
// rule-#131 pick-kind ladder and stamps the built tracks, so metrics
|
||||
// can compare mixes seeded from fresh vs stale engagement.
|
||||
// Score = unskipped-play count + 5 if user has liked the artist. The
|
||||
// Go-side picker (pickSeedArtistsForDay) shuffles the 5
|
||||
// daily-deterministically and takes 3 so the mix set rotates.
|
||||
func (q *Queries) PickSeedArtists(ctx context.Context, userID pgtype.UUID) ([]PickSeedArtistsRow, error) {
|
||||
rows, err := q.db.Query(ctx, pickSeedArtists, userID)
|
||||
if err != nil {
|
||||
@@ -330,7 +386,7 @@ func (q *Queries) PickSeedArtists(ctx context.Context, userID pgtype.UUID) ([]Pi
|
||||
var items []PickSeedArtistsRow
|
||||
for rows.Next() {
|
||||
var i PickSeedArtistsRow
|
||||
if err := rows.Scan(&i.ArtistID, &i.Score); err != nil {
|
||||
if err := rows.Scan(&i.ArtistID, &i.Score, &i.Tier); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
|
||||
@@ -47,14 +47,29 @@ UPDATE system_playlist_runs
|
||||
UPDATE system_playlist_runs SET in_flight = false WHERE in_flight = true;
|
||||
|
||||
-- name: PickSeedArtists :many
|
||||
-- Top-5 most-engaged distinct artist candidates in the user's last 7
|
||||
-- days. The Go-side picker (pickSeedArtistsForDay) shuffles these
|
||||
-- daily-deterministically and takes the first 3 so the set of
|
||||
-- "Songs like X" mixes rotates day-to-day.
|
||||
-- Score = unskipped-play count + 5 if user has liked the artist.
|
||||
WITH plays AS (
|
||||
-- Top-5 most-engaged distinct artist candidates, tiered so the
|
||||
-- "Songs like X" mixes never silently vanish (#1255): the old hard
|
||||
-- 7-day window emptied the seed pool after a quiet week, and the
|
||||
-- daily atomic-replace build then deleted every existing mix until
|
||||
-- the user played something again. Same fallback shape as
|
||||
-- PickTopPlayedTracksForUser (For You's seeds):
|
||||
-- tier 0 engagement in the last 7 days
|
||||
-- tier 1 (only if tier 0 empty) last 30 days
|
||||
-- tier 2 (only if tiers 0+1 empty) all-time
|
||||
-- tier 3 (only if 0-2 empty) liked artists with no play history
|
||||
-- All returned rows share one tier; produceSeedMixes maps it onto the
|
||||
-- rule-#131 pick-kind ladder and stamps the built tracks, so metrics
|
||||
-- can compare mixes seeded from fresh vs stale engagement.
|
||||
-- Score = unskipped-play count + 5 if user has liked the artist. The
|
||||
-- Go-side picker (pickSeedArtistsForDay) shuffles the 5
|
||||
-- daily-deterministically and takes 3 so the mix set rotates.
|
||||
WITH liked AS (
|
||||
SELECT gla.artist_id FROM general_likes_artists gla WHERE gla.user_id = $1
|
||||
),
|
||||
recent7 AS (
|
||||
SELECT t.artist_id,
|
||||
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count
|
||||
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
|
||||
0 AS tier
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
WHERE pe.user_id = $1
|
||||
@@ -62,14 +77,52 @@ WITH plays AS (
|
||||
AND t.artist_id IS NOT NULL
|
||||
GROUP BY t.artist_id
|
||||
),
|
||||
liked AS (
|
||||
SELECT artist_id FROM general_likes_artists WHERE user_id = $1
|
||||
recent30 AS (
|
||||
SELECT t.artist_id,
|
||||
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
|
||||
1 AS tier
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.started_at > now() - INTERVAL '30 days'
|
||||
AND t.artist_id IS NOT NULL
|
||||
GROUP BY t.artist_id
|
||||
),
|
||||
alltime AS (
|
||||
SELECT t.artist_id,
|
||||
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
|
||||
2 AS tier
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
WHERE pe.user_id = $1
|
||||
AND t.artist_id IS NOT NULL
|
||||
GROUP BY t.artist_id
|
||||
),
|
||||
likedonly AS (
|
||||
SELECT l.artist_id, 0::bigint AS play_count, 3 AS tier
|
||||
FROM liked l
|
||||
),
|
||||
chosen AS (
|
||||
SELECT artist_id, play_count, tier FROM recent7
|
||||
UNION ALL
|
||||
SELECT artist_id, play_count, tier FROM recent30
|
||||
WHERE NOT EXISTS (SELECT 1 FROM recent7)
|
||||
UNION ALL
|
||||
SELECT artist_id, play_count, tier FROM alltime
|
||||
WHERE NOT EXISTS (SELECT 1 FROM recent7)
|
||||
AND NOT EXISTS (SELECT 1 FROM recent30)
|
||||
UNION ALL
|
||||
SELECT artist_id, play_count, tier FROM likedonly
|
||||
WHERE NOT EXISTS (SELECT 1 FROM recent7)
|
||||
AND NOT EXISTS (SELECT 1 FROM recent30)
|
||||
AND NOT EXISTS (SELECT 1 FROM alltime)
|
||||
)
|
||||
SELECT p.artist_id,
|
||||
(p.play_count + CASE WHEN l.artist_id IS NOT NULL THEN 5 ELSE 0 END)::bigint AS score
|
||||
FROM plays p
|
||||
LEFT JOIN liked l ON l.artist_id = p.artist_id
|
||||
ORDER BY score DESC, p.artist_id
|
||||
SELECT c.artist_id,
|
||||
(c.play_count + CASE WHEN l.artist_id IS NOT NULL THEN 5 ELSE 0 END)::bigint AS score,
|
||||
c.tier::int AS tier
|
||||
FROM chosen c
|
||||
LEFT JOIN liked l ON l.artist_id = c.artist_id
|
||||
ORDER BY score DESC, c.artist_id
|
||||
LIMIT 5;
|
||||
|
||||
-- name: PickTopPlayedTracksForUser :many
|
||||
|
||||
@@ -46,6 +46,26 @@ func TestPickSeedArtistsFromRows_Empty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickKindForSeedTier(t *testing.T) {
|
||||
// The seed query's fallback tiers map onto the rule-#131 ladder:
|
||||
// fresh 7-day engagement is the exact desire, everything past the
|
||||
// 30-day step-back collapses into the far tier (#1255).
|
||||
cases := []struct {
|
||||
tier int32
|
||||
want string
|
||||
}{
|
||||
{0, pickKindTier1},
|
||||
{1, pickKindTier2},
|
||||
{2, pickKindTier3},
|
||||
{3, pickKindTier3},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := pickKindForSeedTier(c.tier); got != c.want {
|
||||
t.Errorf("pickKindForSeedTier(%d) = %q, want %q", c.tier, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// userIDHash is the per-user, per-day hash that drives the daily-
|
||||
// determinism RNGs. Same family as tieBreakHash, just keyed on user
|
||||
// ID instead of track ID.
|
||||
|
||||
@@ -147,8 +147,28 @@ const (
|
||||
pickKindDormant = "dormant"
|
||||
pickKindCrossUser = "cross_user"
|
||||
pickKindRandom = "random"
|
||||
pickKindTier1 = "tier1"
|
||||
pickKindTier2 = "tier2"
|
||||
pickKindTier3 = "tier3"
|
||||
)
|
||||
|
||||
// pickKindForSeedTier maps PickSeedArtists' seed-pool fallback tier
|
||||
// onto the rule-#131 pick-kind ladder: fresh 7-day engagement pins the
|
||||
// mix's exact desire (tier1), the 30-day window steps back a little
|
||||
// (tier2), and all-time / liked-only seeds are the far fallback
|
||||
// (tier3). Stamped mix-wide — seed staleness is a property of the
|
||||
// whole build, and it's what the metrics breakdown attributes skips to.
|
||||
func pickKindForSeedTier(tier int32) string {
|
||||
switch tier {
|
||||
case 0:
|
||||
return pickKindTier1
|
||||
case 1:
|
||||
return pickKindTier2
|
||||
default:
|
||||
return pickKindTier3
|
||||
}
|
||||
}
|
||||
|
||||
const systemMixLength = 25
|
||||
|
||||
// systemMixWeights are the fixed scoring weights used by the cron worker.
|
||||
@@ -389,8 +409,11 @@ func produceForYou(
|
||||
}
|
||||
|
||||
// produceSeedMixes: up to 3 "Songs like {artist}" mixes. Seed
|
||||
// artists rotate daily-deterministically. The base seed-artist
|
||||
// query failing is fatal; per-artist failures are logged + skipped.
|
||||
// artists rotate daily-deterministically; the seed query falls back
|
||||
// through widening engagement windows (#1255) and every returned row
|
||||
// shares the winning tier, stamped onto the built tracks as their
|
||||
// pick_kind. The base seed-artist query failing is fatal; per-artist
|
||||
// failures are logged + skipped.
|
||||
func produceSeedMixes(
|
||||
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
|
||||
userID pgtype.UUID, dateStr string, now time.Time,
|
||||
@@ -399,6 +422,10 @@ func produceSeedMixes(
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pick seed artists: %w", err)
|
||||
}
|
||||
seedTierKind := ""
|
||||
if len(seedRows) > 0 {
|
||||
seedTierKind = pickKindForSeedTier(seedRows[0].Tier)
|
||||
}
|
||||
seedRowsLocal := make([]seedArtistRow, 0, len(seedRows))
|
||||
for _, r := range seedRows {
|
||||
seedRowsLocal = append(seedRowsLocal, seedArtistRow{
|
||||
@@ -443,6 +470,9 @@ func produceSeedMixes(
|
||||
if len(tracks) == 0 {
|
||||
continue
|
||||
}
|
||||
for i := range tracks {
|
||||
tracks[i].PickKind = seedTierKind
|
||||
}
|
||||
out = append(out, builtPlaylist{
|
||||
Name: fmt.Sprintf("Songs like %s", artistRow.Name),
|
||||
Variant: "songs_like_artist",
|
||||
|
||||
@@ -135,6 +135,59 @@ func TestBuildSystemPlaylists_SufficientActivity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPlaylists_StaleActivity_SongsLikeSurvives(t *testing.T) {
|
||||
// #1255: the seed-artist query's old hard 7-day window emptied the
|
||||
// pool after a quiet week, and the atomic-replace build then deleted
|
||||
// every "Songs like X" mix. With the tiered fallback, plays that are
|
||||
// ~20 days old (outside 7d, inside 30d) must still seed the mixes —
|
||||
// and the built tracks carry the tier2 stamp so metrics can compare
|
||||
// stale-seeded mixes against fresh ones.
|
||||
pool := newPool(t)
|
||||
logger := discardLogger()
|
||||
u := seedUser(t, pool, "stale1")
|
||||
old := time.Now().UTC().Add(-20 * 24 * time.Hour)
|
||||
for a := 0; a < 4; a++ {
|
||||
for k := 0; k < 3; k++ {
|
||||
tk := seedTrack(t, pool,
|
||||
"stale1-track-"+string(rune('A'+a))+string(rune('0'+k)),
|
||||
"stale1-artist-"+string(rune('A'+a)))
|
||||
for p := 0; p < 3; p++ {
|
||||
seedPlayEvent(t, pool, u.ID, tk.ID,
|
||||
old.Add(-time.Duration(a*10+k+p)*time.Hour), false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := playlists.BuildSystemPlaylists(context.Background(), pool, logger, u.ID, time.Now().UTC(), t.TempDir()); err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
|
||||
rows, err := pool.Query(context.Background(), `
|
||||
SELECT DISTINCT COALESCE(pt.pick_kind, '<null>')
|
||||
FROM playlist_tracks pt
|
||||
JOIN playlists p ON p.id = pt.playlist_id
|
||||
WHERE p.user_id = $1 AND p.system_variant = 'songs_like_artist'
|
||||
`, u.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("query pick_kinds: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var kinds []string
|
||||
for rows.Next() {
|
||||
var k string
|
||||
if err := rows.Scan(&k); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
kinds = append(kinds, k)
|
||||
}
|
||||
if len(kinds) == 0 {
|
||||
t.Fatal("expected songs_like_artist tracks from the 30-day fallback tier; got none (the vanish bug)")
|
||||
}
|
||||
if len(kinds) != 1 || kinds[0] != "tier2" {
|
||||
t.Errorf("pick_kinds = %v, want exactly [tier2] (30-day fallback seeds)", kinds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPlaylists_QuarantineExcluded(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
logger := discardLogger()
|
||||
|
||||
Reference in New Issue
Block a user