7226dab9ff
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>
361 lines
12 KiB
Go
361 lines
12 KiB
Go
package playlists
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
const (
|
|
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
|
|
)
|
|
|
|
// 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 (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
|
|
ArtistID pgtype.UUID
|
|
PickKind string
|
|
}
|
|
|
|
// buildDiscoverCandidates assembles the Discover playlist track list.
|
|
// 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).
|
|
// Empty slice when the library has no eligible tracks at all.
|
|
//
|
|
// Individual bucket query failures are logged and treated as empty —
|
|
// 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,
|
|
})
|
|
if err != nil {
|
|
logger.Warn("discover: dormant bucket failed; continuing with empty pool",
|
|
"user_id", uuidStringPL(userID), "err", err)
|
|
dormantRows = nil
|
|
}
|
|
crossUserRows, err := q.ListCrossUserLikedTracksForDiscover(ctx, dbq.ListCrossUserLikedTracksForDiscoverParams{
|
|
UserID: userID, Column2: dateStr,
|
|
})
|
|
if err != nil {
|
|
logger.Warn("discover: cross-user bucket failed; continuing with empty pool",
|
|
"user_id", uuidStringPL(userID), "err", err)
|
|
crossUserRows = nil
|
|
}
|
|
randomRows, err := q.ListRandomUnheardTracksForDiscover(ctx, dbq.ListRandomUnheardTracksForDiscoverParams{
|
|
UserID: userID, Column2: dateStr,
|
|
})
|
|
if err != nil {
|
|
logger.Warn("discover: random bucket failed; continuing with empty pool",
|
|
"user_id", uuidStringPL(userID), "err", err)
|
|
randomRows = nil
|
|
}
|
|
return discoverPools{
|
|
taste: capByAlbumAndArtist(tasteUnheardRowsToTracks(tasteRows)),
|
|
dormant: capByAlbumAndArtist(dormantRowsToTracks(dormantRows)),
|
|
crossUser: capByAlbumAndArtist(crossUserRowsToTracks(crossUserRows)),
|
|
random: capByAlbumAndArtist(randomRowsToTracks(randomRows)),
|
|
}
|
|
}
|
|
|
|
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 {
|
|
out := make([]discoverTrack, len(rows))
|
|
for i, r := range rows {
|
|
out[i] = discoverTrack{
|
|
ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID,
|
|
PickKind: pickKindDormant,
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func crossUserRowsToTracks(rows []dbq.ListCrossUserLikedTracksForDiscoverRow) []discoverTrack {
|
|
out := make([]discoverTrack, len(rows))
|
|
for i, r := range rows {
|
|
out[i] = discoverTrack{
|
|
ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID,
|
|
PickKind: pickKindCrossUser,
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func randomRowsToTracks(rows []dbq.ListRandomUnheardTracksForDiscoverRow) []discoverTrack {
|
|
out := make([]discoverTrack, len(rows))
|
|
for i, r := range rows {
|
|
out[i] = discoverTrack{
|
|
ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID,
|
|
PickKind: pickKindRandom,
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// capByAlbumAndArtist trims a bucket's pool so no single album appears
|
|
// more than discoverMaxTracksPerAlbum times and no single artist
|
|
// appears more than discoverMaxTracksPerArtist times. Preserves input
|
|
// order (which is the daily-deterministic md5 sort).
|
|
//
|
|
// pgtype.UUID's Bytes field is [16]byte, which IS comparable as a map
|
|
// key.
|
|
func capByAlbumAndArtist(rows []discoverTrack) []discoverTrack {
|
|
albumCount := map[pgtype.UUID]int{}
|
|
artistCount := map[pgtype.UUID]int{}
|
|
out := make([]discoverTrack, 0, len(rows))
|
|
for _, r := range rows {
|
|
if albumCount[r.AlbumID] >= discoverMaxTracksPerAlbum {
|
|
continue
|
|
}
|
|
if artistCount[r.ArtistID] >= discoverMaxTracksPerArtist {
|
|
continue
|
|
}
|
|
albumCount[r.AlbumID]++
|
|
artistCount[r.ArtistID]++
|
|
out = append(out, r)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// bucketRequest captures one bucket's desired and available count.
|
|
type bucketRequest struct {
|
|
want int
|
|
available int
|
|
}
|
|
|
|
// redistributeSlots returns the final per-bucket allocation given each
|
|
// bucket's want and available count. When a bucket can't fill its want,
|
|
// the deficit splits equally between the OTHER buckets that still have
|
|
// supply. Sum of returned allocations <= sum of `want`.
|
|
//
|
|
// Algorithm:
|
|
// 1. Initial pass: allocate min(want, available) to each bucket;
|
|
// compute supply = available - allocated.
|
|
// 2. For each deficit bucket (allocated < want), split the deficit
|
|
// equally across peers that still have supply > 0. Track how
|
|
// much of that deficit each pass actually placed (some peers may
|
|
// not have enough supply for their full share).
|
|
// 3. Repeat until no movement happens — handles the case where a
|
|
// peer's supply ran out mid-distribution and the residual needs
|
|
// to roll to other peers in a later pass.
|
|
//
|
|
// Bug history (May 2026): an earlier version recomputed
|
|
// `deficit = b.want - final[i]` every pass without subtracting
|
|
// previously-redistributed amounts, so a bucket with permanently
|
|
// 0 final and want=30 kept handing out 30 to peers each pass. The
|
|
// `redistributed` array below tracks per-source absorbed deficit so
|
|
// subsequent passes only move the residual.
|
|
func redistributeSlots(buckets []bucketRequest) []int {
|
|
n := len(buckets)
|
|
final := make([]int, n)
|
|
supply := make([]int, n)
|
|
redistributed := make([]int, n)
|
|
for i, b := range buckets {
|
|
final[i] = b.want
|
|
if final[i] > b.available {
|
|
final[i] = b.available
|
|
}
|
|
supply[i] = b.available - final[i]
|
|
}
|
|
|
|
for pass := 0; pass < 4; pass++ {
|
|
anyMoved := false
|
|
for i, b := range buckets {
|
|
// Remaining deficit = original gap minus what we've already
|
|
// pushed out to peers in earlier passes.
|
|
deficit := b.want - final[i] - redistributed[i]
|
|
if deficit <= 0 {
|
|
continue
|
|
}
|
|
peers := make([]int, 0, n-1)
|
|
for j := 0; j < n; j++ {
|
|
if j == i {
|
|
continue
|
|
}
|
|
if supply[j] > 0 {
|
|
peers = append(peers, j)
|
|
}
|
|
}
|
|
if len(peers) == 0 {
|
|
continue
|
|
}
|
|
share := deficit / len(peers)
|
|
rem := deficit % len(peers)
|
|
for k, j := range peers {
|
|
take := share
|
|
if k < rem {
|
|
take++
|
|
}
|
|
if take > supply[j] {
|
|
take = supply[j]
|
|
}
|
|
if take > 0 {
|
|
final[j] += take
|
|
supply[j] -= take
|
|
redistributed[i] += take
|
|
anyMoved = true
|
|
}
|
|
}
|
|
}
|
|
if !anyMoved {
|
|
break
|
|
}
|
|
}
|
|
|
|
// Final clamp: total <= discoverTotalSlots.
|
|
sum := 0
|
|
for _, f := range final {
|
|
sum += f
|
|
}
|
|
for sum > discoverTotalSlots {
|
|
// Trim the largest bucket. Should be rare.
|
|
maxVal, idx := -1, -1
|
|
for i, f := range final {
|
|
if f > maxVal {
|
|
maxVal, idx = f, i
|
|
}
|
|
}
|
|
if idx < 0 {
|
|
break
|
|
}
|
|
final[idx]--
|
|
sum--
|
|
}
|
|
return final
|
|
}
|
|
|
|
// interleaveBuckets round-robins items from the buckets in order. The
|
|
// first item of each bucket appears before the second of any bucket.
|
|
// interleaveBuckets walks each bucket round-robin, emitting one track
|
|
// per bucket per pass. Tracks already seen in an earlier bucket (or an
|
|
// earlier pass) are skipped — single-user servers hit this often
|
|
// because the empty crossUser bucket redistributes slots to dormant +
|
|
// random, and a dormant-artist track is also a valid random-unheard
|
|
// pick. Without dedup the same track lands at two interleaved
|
|
// positions, which on a 2-bucket round-robin (dormant+random)
|
|
// produces ADJACENT duplicates in the playlist — exactly the
|
|
// "every song has a duplicate right after it" report from v2026.05.13.0.
|
|
//
|
|
// Dedup priority is bucket order (caller-supplied), so a track in both
|
|
// dormant and random is taken from dormant.
|
|
func interleaveBuckets(buckets ...[]discoverTrack) []discoverTrack {
|
|
total := 0
|
|
for _, b := range buckets {
|
|
total += len(b)
|
|
}
|
|
out := make([]discoverTrack, 0, total)
|
|
seen := make(map[pgtype.UUID]struct{}, total)
|
|
indices := make([]int, len(buckets))
|
|
for {
|
|
anyAppended := false
|
|
for bi, b := range buckets {
|
|
// Advance past tracks already taken from an earlier bucket
|
|
// or an earlier pass.
|
|
for indices[bi] < len(b) {
|
|
if _, dup := seen[b[indices[bi]].ID]; !dup {
|
|
break
|
|
}
|
|
indices[bi]++
|
|
}
|
|
if indices[bi] < len(b) {
|
|
t := b[indices[bi]]
|
|
out = append(out, t)
|
|
seen[t.ID] = struct{}{}
|
|
indices[bi]++
|
|
anyAppended = true
|
|
}
|
|
}
|
|
if !anyAppended {
|
|
break
|
|
}
|
|
}
|
|
return out
|
|
}
|