feat(discover): taste-targeted novelty bucket + rebalance toward discovery
The 2-week metrics review found Discover beating the manual baseline on skip rate — which for a discovery surface means it plays it safe. On a single-user server it's effectively dormant + crude-random, and the per-user taste profile (taste_profile_tags, #796) went unused (#1254 gap). Add a fourth Discover candidate bucket, ListTasteUnheardTracksForDiscover: unheard / non-liked / non-quarantined tracks ranked by summed taste-tag weight over tracks.genre (split like the radio tag_overlap arm), md5 tiebreak. Its picks are stamped pick_kind = 'taste_unheard' (migration 0041 widens the CHECK on playlist_tracks + play_events, rule #36). Rebalance the slot allocation 40/30/30 → taste_unheard 35 / dormant 30 / cross_user 20 / random 15, and lead the interleave with taste_unheard so a track shared with another bucket keeps the taste stamp and the targeted-novelty arm stays measurable. Metrics label "Taste-matched" + order entry added to the single server-side pickKindLabels map, so web and Android surface the new breakdown row with no client change. Cold start (empty taste_profile_tags) yields an empty taste bucket that redistributes to the survivors, so Discover still fills. Scribe #1488. Companion review outcomes: Songs-like starvation already fixed (#1255); For You v2 ratified as-is (fresh-injection cost disproven). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,10 +10,17 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
discoverTotalSlots = 100
|
||||
discoverDormantSlots = 40
|
||||
discoverCrossUserSlots = 30
|
||||
discoverRandomSlots = 30
|
||||
discoverTotalSlots = 100
|
||||
// Bucket allocation, pushed toward *targeted* novelty (Scribe #1488):
|
||||
// the taste-matched-unheard arm gets the plurality, dormant stays a
|
||||
// familiar anchor, and the crude random arm is trimmed to a
|
||||
// serendipity sliver. On a single-user server cross_user is empty and
|
||||
// its slots redistribute, so taste_unheard becomes the dominant real
|
||||
// arm — Discover actually discovers instead of re-warming known artists.
|
||||
discoverTasteUnheardSlots = 35
|
||||
discoverDormantSlots = 30
|
||||
discoverCrossUserSlots = 20
|
||||
discoverRandomSlots = 15
|
||||
discoverMaxTracksPerAlbum = 2
|
||||
discoverMaxTracksPerArtist = 3
|
||||
)
|
||||
@@ -21,9 +28,10 @@ const (
|
||||
// discoverTrack is the common shape used by the bucket allocator. The
|
||||
// three sqlc-generated row types collapse into this internal struct so
|
||||
// downstream functions don't need to be generic over them. PickKind is
|
||||
// the originating bucket (dormant/cross_user/random, #1270), stamped in
|
||||
// the row adapters so it survives the interleave — bucket identity is
|
||||
// what makes the 40/30/30 allocation measurable in the metrics.
|
||||
// the originating bucket (taste_unheard/dormant/cross_user/random,
|
||||
// #1270/#1488), stamped in the row adapters so it survives the
|
||||
// interleave — bucket identity is what makes the slot allocation
|
||||
// measurable in the metrics.
|
||||
type discoverTrack struct {
|
||||
ID pgtype.UUID
|
||||
AlbumID pgtype.UUID
|
||||
@@ -32,8 +40,9 @@ type discoverTrack struct {
|
||||
}
|
||||
|
||||
// buildDiscoverCandidates assembles the Discover playlist track list.
|
||||
// Pulls from three buckets, applies per-album/per-artist caps, then
|
||||
// redistributes any deficit equally across the remaining buckets.
|
||||
// Pulls from four buckets (taste_unheard / dormant / cross_user /
|
||||
// random), applies per-album/per-artist caps, then redistributes any
|
||||
// deficit equally across the remaining buckets.
|
||||
//
|
||||
// Returns up to discoverTotalSlots track IDs in the order they should
|
||||
// appear in the playlist (round-robin interleaved across buckets).
|
||||
@@ -43,6 +52,57 @@ type discoverTrack struct {
|
||||
// the redistribution algorithm rolls the deficit into the surviving
|
||||
// buckets so one broken bucket can't silently kill the whole playlist.
|
||||
func buildDiscoverCandidates(ctx context.Context, q *dbq.Queries, logger *slog.Logger, userID pgtype.UUID, dateStr string) ([]rankedCandidate, error) {
|
||||
pools := loadDiscoverPools(ctx, q, logger, userID, dateStr)
|
||||
|
||||
// Allocate slots with redistribution. Order fixes both the allocation
|
||||
// index mapping and (below) the interleave/dedup priority.
|
||||
allocations := redistributeSlots([]bucketRequest{
|
||||
{want: discoverTasteUnheardSlots, available: len(pools.taste)},
|
||||
{want: discoverDormantSlots, available: len(pools.dormant)},
|
||||
{want: discoverCrossUserSlots, available: len(pools.crossUser)},
|
||||
{want: discoverRandomSlots, available: len(pools.random)},
|
||||
})
|
||||
|
||||
// Round-robin interleave so the playlist doesn't front-load one
|
||||
// flavour. taste_unheard leads so a track shared with another bucket
|
||||
// keeps the taste stamp — the targeted-novelty arm stays measurable.
|
||||
out := interleaveBuckets(
|
||||
pools.taste[:allocations[0]],
|
||||
pools.dormant[:allocations[1]],
|
||||
pools.crossUser[:allocations[2]],
|
||||
pools.random[:allocations[3]],
|
||||
)
|
||||
|
||||
// Convert to rankedCandidate (the type insertSystemPlaylist accepts).
|
||||
// Score is unused for Discover — the daily-deterministic md5 ordering
|
||||
// already gave us the ranking.
|
||||
ranked := make([]rankedCandidate, 0, len(out))
|
||||
for _, t := range out {
|
||||
ranked = append(ranked, rankedCandidate{TrackID: t.ID, PickKind: t.PickKind})
|
||||
}
|
||||
return ranked, nil
|
||||
}
|
||||
|
||||
// discoverPools holds the four Discover candidate buckets after adapting
|
||||
// + per-album/per-artist capping. Bucket query failures degrade to an
|
||||
// empty pool (logged) so one broken bucket can't kill the whole playlist;
|
||||
// the slot redistribution rolls its deficit into the survivors.
|
||||
type discoverPools struct {
|
||||
taste []discoverTrack
|
||||
dormant []discoverTrack
|
||||
crossUser []discoverTrack
|
||||
random []discoverTrack
|
||||
}
|
||||
|
||||
func loadDiscoverPools(ctx context.Context, q *dbq.Queries, logger *slog.Logger, userID pgtype.UUID, dateStr string) discoverPools {
|
||||
tasteRows, err := q.ListTasteUnheardTracksForDiscover(ctx, dbq.ListTasteUnheardTracksForDiscoverParams{
|
||||
UserID: userID, Column2: dateStr,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn("discover: taste-unheard bucket failed; continuing with empty pool",
|
||||
"user_id", uuidStringPL(userID), "err", err)
|
||||
tasteRows = nil
|
||||
}
|
||||
dormantRows, err := q.ListDormantArtistTracksForDiscover(ctx, dbq.ListDormantArtistTracksForDiscoverParams{
|
||||
UserID: userID, Column2: dateStr,
|
||||
})
|
||||
@@ -67,37 +127,23 @@ func buildDiscoverCandidates(ctx context.Context, q *dbq.Queries, logger *slog.L
|
||||
"user_id", uuidStringPL(userID), "err", err)
|
||||
randomRows = nil
|
||||
}
|
||||
|
||||
// Adapt sqlc-generated rows into the internal struct, then apply
|
||||
// per-album / per-artist caps.
|
||||
dormantPool := capByAlbumAndArtist(dormantRowsToTracks(dormantRows))
|
||||
crossUserPool := capByAlbumAndArtist(crossUserRowsToTracks(crossUserRows))
|
||||
randomPool := capByAlbumAndArtist(randomRowsToTracks(randomRows))
|
||||
|
||||
// Allocate slots with redistribution.
|
||||
allocations := redistributeSlots([]bucketRequest{
|
||||
{want: discoverDormantSlots, available: len(dormantPool)},
|
||||
{want: discoverCrossUserSlots, available: len(crossUserPool)},
|
||||
{want: discoverRandomSlots, available: len(randomPool)},
|
||||
})
|
||||
|
||||
// Take the head of each bucket's capped pool.
|
||||
dormantTake := dormantPool[:allocations[0]]
|
||||
crossUserTake := crossUserPool[:allocations[1]]
|
||||
randomTake := randomPool[:allocations[2]]
|
||||
|
||||
// Round-robin interleave so the playlist doesn't front-load one
|
||||
// flavour.
|
||||
out := interleaveBuckets(dormantTake, crossUserTake, randomTake)
|
||||
|
||||
// Convert to rankedCandidate (the type insertSystemPlaylist accepts).
|
||||
// Score is unused for Discover — the daily-deterministic md5 ordering
|
||||
// already gave us the ranking.
|
||||
ranked := make([]rankedCandidate, 0, len(out))
|
||||
for _, t := range out {
|
||||
ranked = append(ranked, rankedCandidate{TrackID: t.ID, PickKind: t.PickKind})
|
||||
return discoverPools{
|
||||
taste: capByAlbumAndArtist(tasteUnheardRowsToTracks(tasteRows)),
|
||||
dormant: capByAlbumAndArtist(dormantRowsToTracks(dormantRows)),
|
||||
crossUser: capByAlbumAndArtist(crossUserRowsToTracks(crossUserRows)),
|
||||
random: capByAlbumAndArtist(randomRowsToTracks(randomRows)),
|
||||
}
|
||||
return ranked, nil
|
||||
}
|
||||
|
||||
func tasteUnheardRowsToTracks(rows []dbq.ListTasteUnheardTracksForDiscoverRow) []discoverTrack {
|
||||
out := make([]discoverTrack, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = discoverTrack{
|
||||
ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID,
|
||||
PickKind: pickKindTasteUnheard,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func dormantRowsToTracks(rows []dbq.ListDormantArtistTracksForDiscoverRow) []discoverTrack {
|
||||
|
||||
@@ -130,17 +130,18 @@ type rankedCandidate struct {
|
||||
// Pick kinds, persisted on playlist_tracks.pick_kind so plays can
|
||||
// attribute skips to the population that sourced the track — For You's
|
||||
// "taste engine missing" vs "freshness tax", Discover's three buckets
|
||||
// (#1270). Values mirror the CHECK in migration 0039; the tier1-3
|
||||
// values there land with the tiered-mix rebuilds.
|
||||
// (#1270). Values mirror the CHECK in migration 0041; taste_unheard is
|
||||
// Discover's taste-targeted-novelty arm (#1488).
|
||||
const (
|
||||
pickKindTaste = "taste"
|
||||
pickKindFresh = "fresh"
|
||||
pickKindDormant = "dormant"
|
||||
pickKindCrossUser = "cross_user"
|
||||
pickKindRandom = "random"
|
||||
pickKindTier1 = "tier1"
|
||||
pickKindTier2 = "tier2"
|
||||
pickKindTier3 = "tier3"
|
||||
pickKindTaste = "taste"
|
||||
pickKindFresh = "fresh"
|
||||
pickKindDormant = "dormant"
|
||||
pickKindTasteUnheard = "taste_unheard"
|
||||
pickKindCrossUser = "cross_user"
|
||||
pickKindRandom = "random"
|
||||
pickKindTier1 = "tier1"
|
||||
pickKindTier2 = "tier2"
|
||||
pickKindTier3 = "tier3"
|
||||
)
|
||||
|
||||
// pickKindForSeedTier maps PickSeedArtists' seed-pool fallback tier
|
||||
|
||||
Reference in New Issue
Block a user