Files
minstrel/internal/playlists/you_might_like.go
T
bvandeusen 6c26ba807e
test-go / test (push) Successful in 37s
test-go / integration (push) Successful in 4m41s
feat(taste): phase 2b — taste_overlap candidate arm (#796)
2a re-ranks the existing pool by TasteMatch; this ensures taste-relevant tracks
ARE in the pool. Adds a 6th arm to LoadRadioCandidatesV2: in-library tracks by
the user's top positively-weighted taste-profile artists ($10 K, weight > 0,
deterministic weight-DESC,id order so it doesn't reintroduce same-day
nondeterminism). Pool-inclusion only (sim_score 0) — TasteMatch already scores
the fit. Empty for cold-start users (no profile).

- CandidateSourceLimits.TasteOverlap; default 20 (radio), 80 for For-You via
  systemForYouSourceLimits.
- You-might-like deliberately sets TasteOverlap=0: it surfaces NOT-actively-
  engaged artists, so flooding its pool with top-taste (mostly already-played)
  artists would just feed the read-time dedup.
- Test: positive-weight artist's track enters via the arm; negative-weight one
  is excluded (weight > 0). Existing pool tests unaffected (no profile seeded).

Deferred within 2b: profile-seeded For-You — marginal given the arm + TasteMatch
already inject taste broadly (top-played seed ≈ top-taste artist).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:05:50 -04:00

234 lines
8.4 KiB
Go

// you_might_like.go builds the per-user "You might like" Home rows
// (#790): in-library albums/artists the listener doesn't actively spin
// but is predicted to enjoy. It reuses the For-You candidate engine
// (similarity + like-weighted scoring) and rolls the per-track scores up
// to album/artist granularity, gated on a minimum listening history so a
// near-empty profile ships nothing rather than noise.
//
// Built in the same daily BuildSystemPlaylists pass and atomic-replaced
// alongside the system playlists; read back by /api/home.
package playlists
import (
"context"
"fmt"
"log/slog"
"math/rand"
"sort"
"time"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
)
const (
// Cold-start gate. Below this much *real* listening the similarity
// roll-up degrades toward random fill, so You-might-like ships
// nothing rather than arbitrary tiles. Both bars must clear: distinct
// tracks rules out "played 3 songs on repeat," distinct artists rules
// out "hammered one album." Conservative defaults; raise if early
// rows feel random on thin libraries.
youMightLikeMinDistinctTracks = 20
youMightLikeMinDistinctArtists = 5
// Persisted depth — a few more than the rendered HomeYouMightLikeLimit
// so the read-time cross-section dedup (vs Most Played / Rediscover)
// has headroom before trimming.
youMightLikeAlbumsN = 12
youMightLikeArtistsN = 12
// One strongly-matched artist shouldn't fill the albums row.
youMightLikeMaxAlbumsPerArtist = 2
// Aggregation: sum of an entity's top-K track scores. Favors albums/
// artists with several good matches over a single outlier track.
youMightLikeAggTopK = 3
)
// youMightLikeResult carries one day's roll-up. built=false means the
// computation failed and the caller must leave any existing rows intact;
// built=true with nil slices means "explicitly empty" (cold-start gate or
// no candidates) and the caller should clear the user's rows.
type youMightLikeResult struct {
albumIDs []pgtype.UUID
artistIDs []pgtype.UUID
built bool
}
// buildYouMightLike computes the user's daily album/artist rolls. Reads
// only — the caller persists inside the build tx. Never returns an error:
// a failure logs and yields built=false so the prior day's rows survive.
func buildYouMightLike(
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
userID pgtype.UUID, dateStr string, now time.Time,
) youMightLikeResult {
signal, err := q.CountListeningSignalForUser(ctx, userID)
if err != nil {
logger.Warn("you-might-like: listening-signal query failed; skipping",
"user_id", uuidStringPL(userID), "err", err)
return youMightLikeResult{built: false}
}
if signal.DistinctTracks < youMightLikeMinDistinctTracks ||
signal.DistinctArtists < youMightLikeMinDistinctArtists {
// Cold start: not enough listening to recommend from. built=true
// clears any stale rows (normally none) and ships an empty row.
return youMightLikeResult{built: true}
}
seeds, err := q.PickTopPlayedTracksForUser(ctx, userID)
if err != nil {
logger.Warn("you-might-like: seed query failed; skipping",
"user_id", uuidStringPL(userID), "err", err)
return youMightLikeResult{built: false}
}
seed := pickForYouSeedForDay(seeds, userID, dateStr)
if !seed.Valid {
return youMightLikeResult{built: true}
}
zeroVec := recommendation.SessionVector{Seed: true}
// You-might-like surfaces in-library artists the user does NOT actively
// engage with, so it deliberately skips the taste_overlap arm — that arm
// pulls top-taste (mostly already-played) artists, which would crowd the
// pool with entities the read-time dedup then strips. For-You/radio keep it.
ymlLimits := systemForYouSourceLimits()
ymlLimits.TasteOverlap = 0
cands, err := recommendation.LoadCandidatesFromSimilarity(
ctx, q, userID, seed, 1, zeroVec,
[]pgtype.UUID{seed}, ymlLimits,
)
if err != nil {
logger.Warn("you-might-like: candidate load failed; skipping",
"user_id", uuidStringPL(userID), "err", err)
return youMightLikeResult{built: false}
}
albumIDs, artistIDs := rollUpCandidates(cands, userID, dateStr, now)
return youMightLikeResult{albumIDs: albumIDs, artistIDs: artistIDs, built: true}
}
// scoredEntity is an album or artist with its aggregated score.
type scoredEntity struct {
id pgtype.UUID
score float64
}
// rollUpCandidates scores every candidate track (systemMixWeights, jitter
// seeded by userIDHash so near-ties rotate day-to-day) and aggregates the
// scores up to album and artist via sum-of-top-K. Returns the top-N album
// and artist IDs, with a per-artist cap on the album list.
func rollUpCandidates(
cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time,
) (albumIDs, artistIDs []pgtype.UUID) {
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
albumScores := map[pgtype.UUID][]float64{}
artistScores := map[pgtype.UUID][]float64{}
albumArtist := map[pgtype.UUID]pgtype.UUID{}
for _, c := range cands {
s := recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64)
if c.Track.AlbumID.Valid {
albumScores[c.Track.AlbumID] = append(albumScores[c.Track.AlbumID], s)
albumArtist[c.Track.AlbumID] = c.Track.ArtistID
}
if c.Track.ArtistID.Valid {
artistScores[c.Track.ArtistID] = append(artistScores[c.Track.ArtistID], s)
}
}
rankedAlbums := capAlbumsPerArtist(
rankEntities(albumScores, dateStr), albumArtist, youMightLikeMaxAlbumsPerArtist)
albumIDs = topNEntityIDs(rankedAlbums, youMightLikeAlbumsN)
artistIDs = topNEntityIDs(rankEntities(artistScores, dateStr), youMightLikeArtistsN)
return albumIDs, artistIDs
}
// rankEntities aggregates each entity's track scores (sum-of-top-K) and
// returns them sorted by score DESC, ties broken deterministically by
// tieBreakHash(id, dateStr) so ordering is stable within a day.
func rankEntities(scoresByEntity map[pgtype.UUID][]float64, dateStr string) []scoredEntity {
out := make([]scoredEntity, 0, len(scoresByEntity))
for id, scores := range scoresByEntity {
out = append(out, scoredEntity{id: id, score: sumTopK(scores, youMightLikeAggTopK)})
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].score != out[j].score {
return out[i].score > out[j].score
}
return tieBreakHash(out[i].id, dateStr) < tieBreakHash(out[j].id, dateStr)
})
return out
}
// sumTopK sorts scores DESC and sums the highest k.
func sumTopK(scores []float64, k int) float64 {
sort.Sort(sort.Reverse(sort.Float64Slice(scores)))
sum := 0.0
for i := 0; i < len(scores) && i < k; i++ {
sum += scores[i]
}
return sum
}
// capAlbumsPerArtist drops albums beyond maxPerArtist for any one artist,
// preserving input (score) order.
func capAlbumsPerArtist(
albums []scoredEntity, albumArtist map[pgtype.UUID]pgtype.UUID, maxPerArtist int,
) []scoredEntity {
perArtist := map[pgtype.UUID]int{}
out := make([]scoredEntity, 0, len(albums))
for _, a := range albums {
art := albumArtist[a.id]
if art.Valid {
if perArtist[art] >= maxPerArtist {
continue
}
perArtist[art]++
}
out = append(out, a)
}
return out
}
// topNEntityIDs truncates to n and projects the IDs in rank order.
func topNEntityIDs(entities []scoredEntity, n int) []pgtype.UUID {
if len(entities) > n {
entities = entities[:n]
}
out := make([]pgtype.UUID, 0, len(entities))
for _, e := range entities {
out = append(out, e.id)
}
return out
}
// persistYouMightLike atomic-replaces the user's You-might-like rows
// inside the build tx. Called only when the roll-up was freshly built;
// a failed computation leaves the prior rows untouched.
func persistYouMightLike(
ctx context.Context, qtx *dbq.Queries, userID pgtype.UUID, r youMightLikeResult,
) error {
if err := qtx.DeleteYouMightLikeAlbumsForUser(ctx, userID); err != nil {
return fmt.Errorf("delete you-might-like albums: %w", err)
}
for i, id := range r.albumIDs {
if err := qtx.InsertYouMightLikeAlbum(ctx, dbq.InsertYouMightLikeAlbumParams{
UserID: userID, AlbumID: id, Rank: int32(i),
}); err != nil {
return fmt.Errorf("insert you-might-like album: %w", err)
}
}
if err := qtx.DeleteYouMightLikeArtistsForUser(ctx, userID); err != nil {
return fmt.Errorf("delete you-might-like artists: %w", err)
}
for i, id := range r.artistIDs {
if err := qtx.InsertYouMightLikeArtist(ctx, dbq.InsertYouMightLikeArtistParams{
UserID: userID, ArtistID: id, Rank: int32(i),
}); err != nil {
return fmt.Errorf("insert you-might-like artist: %w", err)
}
}
return nil
}