Files
minstrel/internal/playlists/you_might_like.go
T
bvandeusen 0d0a8f46b1
test-go / test (push) Failing after 14s
test-web / test (push) Successful in 34s
test-go / integration (push) Successful in 4m42s
feat(tuning): scoring weights → DB-backed admin tuning lab
The recommendation scoring knobs move out of YAML (radio profile) and
out of the systemMixWeights hard-code (daily_mix profile) into
DB-backed settings with live effect (#1250) — the defaults-discovery
lab per decision #1247: the operator turns knobs to find good values,
which then get baked back into shipped defaults; end users and other
operators should never need the card.

- Migration 0040: recommendation_weight_profiles (radio / daily_mix,
  8 weight columns), taste_tuning singleton (engagement half-life +
  completion-curve points), recommendation_tuning_audit (one row per
  change with a {field, old, new} diff — the trend view's markers,
  #1251).
- internal/recsettings: boot reconcile seeds shipped defaults without
  clobbering tuned rows (coverart SettingsService pattern), validates
  patches (bounds, curve ordering), writes audit rows, and pushes
  daily_mix weights + taste config into package playlists. No-op
  patches write no audit row.
- playlists gains SetSystemMixWeights / SetTasteConfig swap points
  under a RWMutex — no signature threading through the producers; the
  scheduler's taste rebuild reads the pushed config.
- Radio reads its weight profile from the service per request; the 8
  weight fields leave config.RecommendationConfig (YAML keeps only
  RecentlyPlayedHours / RadioSize / RadioSizeMax).
- Admin API: GET/PATCH/reset under /api/admin/recommendation-tuning,
  echoing current + shipped values.
- Web: new admin Tuning tab — two weight profiles side by side, taste
  card, per-scope save (changed fields only) + reset, deviation dots
  against shipped defaults.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
2026-07-03 09:22:03 -04:00

239 lines
8.7 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))))
weights := currentSystemMixWeights()
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, weights, 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
}