9e02878b61
Two approved composition changes (#1269), mechanism only — the taste/fresh share stays data-decided (#1252) and pick_kind attribution is unchanged. Multi-seed blending: each day's build now seeds from up to 3 of the user's top-5 tracks (pickDailySeeds, the generalized daily shuffle) instead of one rotating anchor, so the mix spans neighborhoods within a day and stops feeling bipolar as the rotation swings between dissimilar seeds. Per-seed pools merge first-seen-deduped; the head is filled best-first under 50/30/20 per-seed quotas (60/40 for two seeds) so one neighborhood can't monopolize it, with thin-seed quota spilling best-first. Score-weighted fresh tail: the tail sample (rank 2*headN onward) was uniform — the 380th-best candidate as likely as the 101st. It now uses deterministic Efraimidis-Spirakis keys with weight halving every 50 ranks, so freshness keeps its "you'll probably enjoy this" half while still rotating daily. The retired single-seed picker's one other caller, You-might-like, moves to pickDailySeeds(n=1) — a single neighborhood per day is right for a short shelf, and the behavior note is inline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
238 lines
8.6 KiB
Go
238 lines
8.6 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}
|
|
}
|
|
// One rotating seed is right here (unlike For-You's multi-seed
|
|
// blend, #1269): the row is a short shelf, not a mix, and a single
|
|
// neighborhood per day keeps it coherent.
|
|
daily := pickDailySeeds(seeds, userID, dateStr, 1)
|
|
if len(daily) == 0 {
|
|
return youMightLikeResult{built: true}
|
|
}
|
|
seed := daily[0]
|
|
|
|
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
|
|
}
|