diff --git a/internal/api/home.go b/internal/api/home.go index f0f14640..0e2daec5 100644 --- a/internal/api/home.go +++ b/internal/api/home.go @@ -30,6 +30,8 @@ func (h *handlers) handleGetHome(w http.ResponseWriter, r *http.Request) { RediscoverArtists: make([]ArtistRef, 0, len(data.RediscoverArtists)), MostPlayedTracks: make([]TrackRef, 0, len(data.MostPlayedTracks)), LastPlayedArtists: make([]ArtistRef, 0, len(data.LastPlayedArtists)), + YouMightLikeAlbums: make([]AlbumRef, 0, len(data.YouMightLikeAlbums)), + YouMightLikeArtists: make([]ArtistRef, 0, len(data.YouMightLikeArtists)), } for _, row := range data.RecentlyAddedAlbums { out.RecentlyAddedAlbums = append(out.RecentlyAddedAlbums, albumRefFrom(row.Album, row.ArtistName, 0, 0)) @@ -46,6 +48,12 @@ func (h *handlers) handleGetHome(w http.ResponseWriter, r *http.Request) { for _, row := range data.LastPlayedArtists { out.LastPlayedArtists = append(out.LastPlayedArtists, artistRefFromCovered(row.Artist, int(row.AlbumCount), row.CoverAlbumID)) } + for _, row := range data.YouMightLikeAlbums { + out.YouMightLikeAlbums = append(out.YouMightLikeAlbums, albumRefFrom(row.Album, row.ArtistName, 0, 0)) + } + for _, row := range data.YouMightLikeArtists { + out.YouMightLikeArtists = append(out.YouMightLikeArtists, artistRefFromCovered(row.Artist, int(row.AlbumCount), row.CoverAlbumID)) + } writeJSON(w, http.StatusOK, out) } @@ -78,6 +86,8 @@ func (h *handlers) handleGetHomeIndex(w http.ResponseWriter, r *http.Request) { RediscoverArtists: make([]string, 0, len(data.RediscoverArtists)), MostPlayedTracks: make([]string, 0, len(data.MostPlayedTracks)), LastPlayedArtists: make([]string, 0, len(data.LastPlayedArtists)), + YouMightLikeAlbums: make([]string, 0, len(data.YouMightLikeAlbums)), + YouMightLikeArtists: make([]string, 0, len(data.YouMightLikeArtists)), } for _, row := range data.RecentlyAddedAlbums { out.RecentlyAddedAlbums = append(out.RecentlyAddedAlbums, uuidToString(row.Album.ID)) @@ -94,6 +104,12 @@ func (h *handlers) handleGetHomeIndex(w http.ResponseWriter, r *http.Request) { for _, row := range data.LastPlayedArtists { out.LastPlayedArtists = append(out.LastPlayedArtists, uuidToString(row.Artist.ID)) } + for _, row := range data.YouMightLikeAlbums { + out.YouMightLikeAlbums = append(out.YouMightLikeAlbums, uuidToString(row.Album.ID)) + } + for _, row := range data.YouMightLikeArtists { + out.YouMightLikeArtists = append(out.YouMightLikeArtists, uuidToString(row.Artist.ID)) + } writeJSON(w, http.StatusOK, out) } diff --git a/internal/api/radio.go b/internal/api/radio.go index 5896324d..c99a26f2 100644 --- a/internal/api/radio.go +++ b/internal/api/radio.go @@ -108,6 +108,7 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) { JitterMagnitude: h.recCfg.JitterMagnitude, ContextWeight: h.recCfg.ContextWeight, SimilarityWeight: h.recCfg.SimilarityWeight, + TasteWeight: h.recCfg.TasteWeight, } picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1) diff --git a/internal/api/types.go b/internal/api/types.go index d9fc0c06..bc9f98d1 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -116,6 +116,11 @@ type HomePayload struct { RediscoverArtists []ArtistRef `json:"rediscover_artists"` MostPlayedTracks []TrackRef `json:"most_played_tracks"` LastPlayedArtists []ArtistRef `json:"last_played_artists"` + // You-might-like: in-library albums/artists predicted from the user's + // listening that they don't actively spin. Built daily; gated on a + // minimum listening history so a thin profile gets empty rows. + YouMightLikeAlbums []AlbumRef `json:"you_might_like_albums"` + YouMightLikeArtists []ArtistRef `json:"you_might_like_artists"` } // HomeIndexPayload is the response body of GET /api/home/index — the @@ -134,6 +139,8 @@ type HomeIndexPayload struct { RediscoverArtists []string `json:"rediscover_artists"` MostPlayedTracks []string `json:"most_played_tracks"` LastPlayedArtists []string `json:"last_played_artists"` + YouMightLikeAlbums []string `json:"you_might_like_albums"` + YouMightLikeArtists []string `json:"you_might_like_artists"` } // HistoryEvent is one play in a user's listening history. Used by diff --git a/internal/config/config.go b/internal/config/config.go index e28d4432..f810a988 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -98,6 +98,7 @@ type RecommendationConfig struct { JitterMagnitude float64 `yaml:"jitter_magnitude"` ContextWeight float64 `yaml:"context_weight"` SimilarityWeight float64 `yaml:"similarity_weight"` + TasteWeight float64 `yaml:"taste_weight"` RecentlyPlayedHours int `yaml:"recently_played_hours"` RadioSize int `yaml:"radio_size"` RadioSizeMax int `yaml:"radio_size_max"` @@ -119,13 +120,16 @@ func Default() Config { SkipMaxDurationPlayedMs: 30000, }, Recommendation: RecommendationConfig{ - BaseWeight: 1.0, - LikeBoost: 2.0, - RecencyWeight: 1.0, - SkipPenalty: 1.0, - JitterMagnitude: 0.1, - ContextWeight: 2.0, - SimilarityWeight: 2.0, + BaseWeight: 1.0, + LikeBoost: 2.0, + RecencyWeight: 1.0, + SkipPenalty: 1.0, + JitterMagnitude: 0.1, + ContextWeight: 2.0, + SimilarityWeight: 2.0, + // Radio is seed-directed (the user picked a direction), so taste + // is a lighter nudge here than in the daily mixes (1.5). + TasteWeight: 1.0, RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200, diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index 3556ce8e..a30f8d86 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -497,6 +497,20 @@ type SystemPlaylistRun struct { LastError *string } +type TasteProfileArtist struct { + UserID pgtype.UUID + ArtistID pgtype.UUID + Weight float64 + UpdatedAt pgtype.Timestamptz +} + +type TasteProfileTag struct { + UserID pgtype.UUID + Tag string + Weight float64 + UpdatedAt pgtype.Timestamptz +} + type Track struct { ID pgtype.UUID Title string @@ -549,3 +563,17 @@ type UserInvite struct { RedeemedAt pgtype.Timestamptz RedeemedBy pgtype.UUID } + +type YouMightLikeAlbum struct { + UserID pgtype.UUID + AlbumID pgtype.UUID + Rank int32 + BuiltAt pgtype.Timestamptz +} + +type YouMightLikeArtist struct { + UserID pgtype.UUID + ArtistID pgtype.UUID + Rank int32 + BuiltAt pgtype.Timestamptz +} diff --git a/internal/db/dbq/taste_profile.sql.go b/internal/db/dbq/taste_profile.sql.go new file mode 100644 index 00000000..db865aab --- /dev/null +++ b/internal/db/dbq/taste_profile.sql.go @@ -0,0 +1,262 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: taste_profile.sql + +package dbq + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const deleteTasteProfileArtistsForUser = `-- name: DeleteTasteProfileArtistsForUser :exec +DELETE FROM taste_profile_artists WHERE user_id = $1 +` + +func (q *Queries) DeleteTasteProfileArtistsForUser(ctx context.Context, userID pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteTasteProfileArtistsForUser, userID) + return err +} + +const deleteTasteProfileTagsForUser = `-- name: DeleteTasteProfileTagsForUser :exec +DELETE FROM taste_profile_tags WHERE user_id = $1 +` + +func (q *Queries) DeleteTasteProfileTagsForUser(ctx context.Context, userID pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteTasteProfileTagsForUser, userID) + return err +} + +const insertTasteProfileArtist = `-- name: InsertTasteProfileArtist :exec +INSERT INTO taste_profile_artists (user_id, artist_id, weight) +VALUES ($1, $2, $3) +` + +type InsertTasteProfileArtistParams struct { + UserID pgtype.UUID + ArtistID pgtype.UUID + Weight float64 +} + +func (q *Queries) InsertTasteProfileArtist(ctx context.Context, arg InsertTasteProfileArtistParams) error { + _, err := q.db.Exec(ctx, insertTasteProfileArtist, arg.UserID, arg.ArtistID, arg.Weight) + return err +} + +const insertTasteProfileTag = `-- name: InsertTasteProfileTag :exec +INSERT INTO taste_profile_tags (user_id, tag, weight) +VALUES ($1, $2, $3) +` + +type InsertTasteProfileTagParams struct { + UserID pgtype.UUID + Tag string + Weight float64 +} + +func (q *Queries) InsertTasteProfileTag(ctx context.Context, arg InsertTasteProfileTagParams) error { + _, err := q.db.Exec(ctx, insertTasteProfileTag, arg.UserID, arg.Tag, arg.Weight) + return err +} + +const listLikedArtistIDsForUser = `-- name: ListLikedArtistIDsForUser :many +SELECT artist_id FROM general_likes_artists WHERE user_id = $1 +` + +// Artists the user has explicitly liked; feeds the artist-like bonus. +func (q *Queries) ListLikedArtistIDsForUser(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) { + rows, err := q.db.Query(ctx, listLikedArtistIDsForUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []pgtype.UUID + for rows.Next() { + var artist_id pgtype.UUID + if err := rows.Scan(&artist_id); err != nil { + return nil, err + } + items = append(items, artist_id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listLikedTrackTasteInputsForUser = `-- name: ListLikedTrackTasteInputsForUser :many +SELECT t.artist_id, t.genre +FROM general_likes gl +JOIN tracks t ON t.id = gl.track_id +WHERE gl.user_id = $1 +` + +type ListLikedTrackTasteInputsForUserRow struct { + ArtistID pgtype.UUID + Genre *string +} + +// (artist_id, genre) for each track the user has explicitly liked. Feeds the +// track-like bonus into the liked track's artist and tags. +func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID pgtype.UUID) ([]ListLikedTrackTasteInputsForUserRow, error) { + rows, err := q.db.Query(ctx, listLikedTrackTasteInputsForUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListLikedTrackTasteInputsForUserRow + for rows.Next() { + var i ListLikedTrackTasteInputsForUserRow + if err := rows.Scan(&i.ArtistID, &i.Genre); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listPlayEngagementInputsForUser = `-- name: ListPlayEngagementInputsForUser :many + +SELECT + t.artist_id, + t.genre, + LEAST(GREATEST( + COALESCE(pe.completion_ratio, + pe.duration_played_ms::float8 / NULLIF(t.duration_ms, 0), + 0), 0), 1)::float8 AS completion, + (EXTRACT(epoch FROM now() - pe.started_at) / 86400.0)::float8 AS age_days +FROM play_events pe +JOIN tracks t ON t.id = pe.track_id +WHERE pe.user_id = $1 + AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day') + AND NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $1 AND q.track_id = t.id + ) +` + +type ListPlayEngagementInputsForUserParams struct { + UserID pgtype.UUID + Column2 float64 +} + +type ListPlayEngagementInputsForUserRow struct { + ArtistID pgtype.UUID + Genre *string + Completion float64 + AgeDays float64 +} + +// Taste profile (#796 phase 1). The daily build computes signed artist/tag +// weights in Go (graded engagement + decay + like bonus) and atomic-replaces +// them here. Read queries serve "top taste" lookups (used in phase 2). +// One row per play in the decay-relevant window. completion is the play's +// completion ratio (precomputed column when present, else duration_played / +// track duration, clamped to [0,1]); age_days drives the time-decay. Genre +// is split into tags in Go. Quarantined tracks are excluded. +func (q *Queries) ListPlayEngagementInputsForUser(ctx context.Context, arg ListPlayEngagementInputsForUserParams) ([]ListPlayEngagementInputsForUserRow, error) { + rows, err := q.db.Query(ctx, listPlayEngagementInputsForUser, arg.UserID, arg.Column2) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListPlayEngagementInputsForUserRow + for rows.Next() { + var i ListPlayEngagementInputsForUserRow + if err := rows.Scan( + &i.ArtistID, + &i.Genre, + &i.Completion, + &i.AgeDays, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listTasteProfileArtistsForUser = `-- name: ListTasteProfileArtistsForUser :many +SELECT artist_id, weight +FROM taste_profile_artists +WHERE user_id = $1 +ORDER BY weight DESC +LIMIT $2 +` + +type ListTasteProfileArtistsForUserParams struct { + UserID pgtype.UUID + Limit int32 +} + +type ListTasteProfileArtistsForUserRow struct { + ArtistID pgtype.UUID + Weight float64 +} + +// Top-weighted taste artists (phase-2 consumption + tests). +func (q *Queries) ListTasteProfileArtistsForUser(ctx context.Context, arg ListTasteProfileArtistsForUserParams) ([]ListTasteProfileArtistsForUserRow, error) { + rows, err := q.db.Query(ctx, listTasteProfileArtistsForUser, arg.UserID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListTasteProfileArtistsForUserRow + for rows.Next() { + var i ListTasteProfileArtistsForUserRow + if err := rows.Scan(&i.ArtistID, &i.Weight); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listTasteProfileTagsForUser = `-- name: ListTasteProfileTagsForUser :many +SELECT tag, weight +FROM taste_profile_tags +WHERE user_id = $1 +ORDER BY weight DESC +LIMIT $2 +` + +type ListTasteProfileTagsForUserParams struct { + UserID pgtype.UUID + Limit int32 +} + +type ListTasteProfileTagsForUserRow struct { + Tag string + Weight float64 +} + +func (q *Queries) ListTasteProfileTagsForUser(ctx context.Context, arg ListTasteProfileTagsForUserParams) ([]ListTasteProfileTagsForUserRow, error) { + rows, err := q.db.Query(ctx, listTasteProfileTagsForUser, arg.UserID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListTasteProfileTagsForUserRow + for rows.Next() { + var i ListTasteProfileTagsForUserRow + if err := rows.Scan(&i.Tag, &i.Weight); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/db/dbq/you_might_like.sql.go b/internal/db/dbq/you_might_like.sql.go new file mode 100644 index 00000000..4f2a98c0 --- /dev/null +++ b/internal/db/dbq/you_might_like.sql.go @@ -0,0 +1,217 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: you_might_like.sql + +package dbq + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const countListeningSignalForUser = `-- name: CountListeningSignalForUser :one + +SELECT + count(DISTINCT pe.track_id)::bigint AS distinct_tracks, + count(DISTINCT t.artist_id)::bigint AS distinct_artists +FROM play_events pe +JOIN tracks t ON t.id = pe.track_id +WHERE pe.user_id = $1 AND pe.was_skipped = false +` + +type CountListeningSignalForUserRow struct { + DistinctTracks int64 + DistinctArtists int64 +} + +// "You might like" Home rows (#790). The daily build computes ranked +// album/artist IDs in Go (similarity roll-up + cold-start gate) and +// atomic-replaces them here; /api/home reads them back hydrated. +// Cold-start gate. Returns the breadth of the user's real listening: +// distinct unskipped tracks played and distinct artists played. The +// build skips You-might-like entirely below a threshold (a thin history +// yields near-random roll-ups). was_skipped=false so a user spamming +// next can't inflate the signal. +func (q *Queries) CountListeningSignalForUser(ctx context.Context, userID pgtype.UUID) (CountListeningSignalForUserRow, error) { + row := q.db.QueryRow(ctx, countListeningSignalForUser, userID) + var i CountListeningSignalForUserRow + err := row.Scan(&i.DistinctTracks, &i.DistinctArtists) + return i, err +} + +const deleteYouMightLikeAlbumsForUser = `-- name: DeleteYouMightLikeAlbumsForUser :exec +DELETE FROM you_might_like_albums WHERE user_id = $1 +` + +func (q *Queries) DeleteYouMightLikeAlbumsForUser(ctx context.Context, userID pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteYouMightLikeAlbumsForUser, userID) + return err +} + +const deleteYouMightLikeArtistsForUser = `-- name: DeleteYouMightLikeArtistsForUser :exec +DELETE FROM you_might_like_artists WHERE user_id = $1 +` + +func (q *Queries) DeleteYouMightLikeArtistsForUser(ctx context.Context, userID pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteYouMightLikeArtistsForUser, userID) + return err +} + +const insertYouMightLikeAlbum = `-- name: InsertYouMightLikeAlbum :exec +INSERT INTO you_might_like_albums (user_id, album_id, rank) +VALUES ($1, $2, $3) +` + +type InsertYouMightLikeAlbumParams struct { + UserID pgtype.UUID + AlbumID pgtype.UUID + Rank int32 +} + +func (q *Queries) InsertYouMightLikeAlbum(ctx context.Context, arg InsertYouMightLikeAlbumParams) error { + _, err := q.db.Exec(ctx, insertYouMightLikeAlbum, arg.UserID, arg.AlbumID, arg.Rank) + return err +} + +const insertYouMightLikeArtist = `-- name: InsertYouMightLikeArtist :exec +INSERT INTO you_might_like_artists (user_id, artist_id, rank) +VALUES ($1, $2, $3) +` + +type InsertYouMightLikeArtistParams struct { + UserID pgtype.UUID + ArtistID pgtype.UUID + Rank int32 +} + +func (q *Queries) InsertYouMightLikeArtist(ctx context.Context, arg InsertYouMightLikeArtistParams) error { + _, err := q.db.Exec(ctx, insertYouMightLikeArtist, arg.UserID, arg.ArtistID, arg.Rank) + return err +} + +const listYouMightLikeAlbumsForUser = `-- name: ListYouMightLikeAlbumsForUser :many +SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name +FROM you_might_like_albums yml +JOIN albums ON albums.id = yml.album_id +JOIN artists ON artists.id = albums.artist_id +WHERE yml.user_id = $1 +ORDER BY yml.rank +LIMIT $2 +` + +type ListYouMightLikeAlbumsForUserParams struct { + UserID pgtype.UUID + Limit int32 +} + +type ListYouMightLikeAlbumsForUserRow struct { + Album Album + ArtistName string +} + +// Read path. Same projection as ListRediscoverAlbumsForUser so the API +// layer reuses albumRefFrom(row.Album, row.ArtistName, …). Ordered by +// the rank persisted at build time. Final cross-section dedup (vs Most +// Played / Rediscover) and the output cap land in the Go layer +// (internal/recommendation/home.go). +func (q *Queries) ListYouMightLikeAlbumsForUser(ctx context.Context, arg ListYouMightLikeAlbumsForUserParams) ([]ListYouMightLikeAlbumsForUserRow, error) { + rows, err := q.db.Query(ctx, listYouMightLikeAlbumsForUser, arg.UserID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListYouMightLikeAlbumsForUserRow + for rows.Next() { + var i ListYouMightLikeAlbumsForUserRow + if err := rows.Scan( + &i.Album.ID, + &i.Album.Title, + &i.Album.SortTitle, + &i.Album.ArtistID, + &i.Album.ReleaseDate, + &i.Album.Mbid, + &i.Album.CoverArtPath, + &i.Album.CreatedAt, + &i.Album.UpdatedAt, + &i.Album.CoverArtSource, + &i.Album.CoverArtSourcesVersion, + &i.ArtistName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listYouMightLikeArtistsForUser = `-- name: ListYouMightLikeArtistsForUser :many +SELECT artists.id, artists.name, artists.sort_name, artists.mbid, artists.created_at, artists.updated_at, artists.artist_thumb_path, artists.artist_fanart_path, artists.artist_art_source, artists.artist_art_sources_version, + cov.id AS cover_album_id, + cnt.album_count::bigint AS album_count +FROM you_might_like_artists yml +JOIN artists ON artists.id = yml.artist_id +LEFT JOIN LATERAL ( + SELECT id FROM albums + WHERE artist_id = artists.id AND cover_art_path IS NOT NULL + ORDER BY created_at DESC LIMIT 1 +) cov ON true +LEFT JOIN LATERAL ( + SELECT count(*) AS album_count + FROM albums WHERE artist_id = artists.id +) cnt ON true +WHERE yml.user_id = $1 +ORDER BY yml.rank +LIMIT $2 +` + +type ListYouMightLikeArtistsForUserParams struct { + UserID pgtype.UUID + Limit int32 +} + +type ListYouMightLikeArtistsForUserRow struct { + Artist Artist + CoverAlbumID pgtype.UUID + AlbumCount int64 +} + +// Read path. Same projection as ListRediscoverArtistsForUser (embeds the +// artist + a representative cover_album_id + album_count) so the API +// layer reuses artistRefFromCovered. Ordered by persisted rank. +func (q *Queries) ListYouMightLikeArtistsForUser(ctx context.Context, arg ListYouMightLikeArtistsForUserParams) ([]ListYouMightLikeArtistsForUserRow, error) { + rows, err := q.db.Query(ctx, listYouMightLikeArtistsForUser, arg.UserID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListYouMightLikeArtistsForUserRow + for rows.Next() { + var i ListYouMightLikeArtistsForUserRow + if err := rows.Scan( + &i.Artist.ID, + &i.Artist.Name, + &i.Artist.SortName, + &i.Artist.Mbid, + &i.Artist.CreatedAt, + &i.Artist.UpdatedAt, + &i.Artist.ArtistThumbPath, + &i.Artist.ArtistFanartPath, + &i.Artist.ArtistArtSource, + &i.Artist.ArtistArtSourcesVersion, + &i.CoverAlbumID, + &i.AlbumCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/db/migrations/0034_you_might_like.down.sql b/internal/db/migrations/0034_you_might_like.down.sql new file mode 100644 index 00000000..e31679d6 --- /dev/null +++ b/internal/db/migrations/0034_you_might_like.down.sql @@ -0,0 +1,3 @@ +-- Reverse 0034_you_might_like.up.sql. +DROP TABLE IF EXISTS you_might_like_artists; +DROP TABLE IF EXISTS you_might_like_albums; diff --git a/internal/db/migrations/0034_you_might_like.up.sql b/internal/db/migrations/0034_you_might_like.up.sql new file mode 100644 index 00000000..dc9cf162 --- /dev/null +++ b/internal/db/migrations/0034_you_might_like.up.sql @@ -0,0 +1,36 @@ +-- 0034_you_might_like.up.sql — "You might like" Home rows (#790). +-- +-- Per-user ranked lists of IN-LIBRARY albums/artists the listener +-- doesn't actively spin but is predicted to enjoy, derived from the +-- same similarity + like-weighted candidate engine that powers For-You +-- (rolled up from track scores to album/artist). Built in the daily +-- 3am BuildSystemPlaylists pass and atomic-replaced, exactly like the +-- system playlists; read back by /api/home. +-- +-- Two thin tables (id + rank) rather than denormalized snapshots: the +-- Home read path hydrates album/artist refs through the existing +-- albums/artists joins, so a later metadata edit is reflected without +-- a rebuild. CASCADE on user/album/artist deletes keeps them clean. + +CREATE TABLE you_might_like_albums ( + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + album_id uuid NOT NULL REFERENCES albums(id) ON DELETE CASCADE, + rank integer NOT NULL, + built_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, album_id) +); + +-- Read path orders by rank within a user; the index serves it directly. +CREATE INDEX you_might_like_albums_user_rank_idx + ON you_might_like_albums (user_id, rank); + +CREATE TABLE you_might_like_artists ( + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + artist_id uuid NOT NULL REFERENCES artists(id) ON DELETE CASCADE, + rank integer NOT NULL, + built_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, artist_id) +); + +CREATE INDEX you_might_like_artists_user_rank_idx + ON you_might_like_artists (user_id, rank); diff --git a/internal/db/migrations/0035_taste_profile.down.sql b/internal/db/migrations/0035_taste_profile.down.sql new file mode 100644 index 00000000..4af083e7 --- /dev/null +++ b/internal/db/migrations/0035_taste_profile.down.sql @@ -0,0 +1,3 @@ +-- Reverse 0035_taste_profile.up.sql. +DROP TABLE IF EXISTS taste_profile_tags; +DROP TABLE IF EXISTS taste_profile_artists; diff --git a/internal/db/migrations/0035_taste_profile.up.sql b/internal/db/migrations/0035_taste_profile.up.sql new file mode 100644 index 00000000..595375a3 --- /dev/null +++ b/internal/db/migrations/0035_taste_profile.up.sql @@ -0,0 +1,33 @@ +-- 0035_taste_profile.up.sql — per-user taste profile (#796 phase 1). +-- +-- A persistent, decaying model of each user's taste, rebuilt daily from +-- graded play engagement (completion + skip position) plus explicit +-- likes. Two facet tables: weighted artists and weighted genre tags. +-- Weight is a signed float — positive = drawn to, negative = (passively) +-- avoided; magnitude reflects decayed engagement. Phase 1 only BUILDS +-- these; consumption (candidate sourcing + scoring) lands in phase 2. +-- +-- CASCADE on user/artist deletes keeps them clean. Indexed by +-- (user_id, weight DESC) to serve "top taste artists/tags" reads cheaply. + +CREATE TABLE taste_profile_artists ( + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + artist_id uuid NOT NULL REFERENCES artists(id) ON DELETE CASCADE, + weight double precision NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, artist_id) +); + +CREATE INDEX taste_profile_artists_user_weight_idx + ON taste_profile_artists (user_id, weight DESC); + +CREATE TABLE taste_profile_tags ( + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tag text NOT NULL, + weight double precision NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, tag) +); + +CREATE INDEX taste_profile_tags_user_weight_idx + ON taste_profile_tags (user_id, weight DESC); diff --git a/internal/db/queries/taste_profile.sql b/internal/db/queries/taste_profile.sql new file mode 100644 index 00000000..01387670 --- /dev/null +++ b/internal/db/queries/taste_profile.sql @@ -0,0 +1,66 @@ +-- Taste profile (#796 phase 1). The daily build computes signed artist/tag +-- weights in Go (graded engagement + decay + like bonus) and atomic-replaces +-- them here. Read queries serve "top taste" lookups (used in phase 2). + +-- name: ListPlayEngagementInputsForUser :many +-- One row per play in the decay-relevant window. completion is the play's +-- completion ratio (precomputed column when present, else duration_played / +-- track duration, clamped to [0,1]); age_days drives the time-decay. Genre +-- is split into tags in Go. Quarantined tracks are excluded. +SELECT + t.artist_id, + t.genre, + LEAST(GREATEST( + COALESCE(pe.completion_ratio, + pe.duration_played_ms::float8 / NULLIF(t.duration_ms, 0), + 0), 0), 1)::float8 AS completion, + (EXTRACT(epoch FROM now() - pe.started_at) / 86400.0)::float8 AS age_days +FROM play_events pe +JOIN tracks t ON t.id = pe.track_id +WHERE pe.user_id = $1 + AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day') + AND NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $1 AND q.track_id = t.id + ); + +-- name: ListLikedTrackTasteInputsForUser :many +-- (artist_id, genre) for each track the user has explicitly liked. Feeds the +-- track-like bonus into the liked track's artist and tags. +SELECT t.artist_id, t.genre +FROM general_likes gl +JOIN tracks t ON t.id = gl.track_id +WHERE gl.user_id = $1; + +-- name: ListLikedArtistIDsForUser :many +-- Artists the user has explicitly liked; feeds the artist-like bonus. +SELECT artist_id FROM general_likes_artists WHERE user_id = $1; + +-- name: DeleteTasteProfileArtistsForUser :exec +DELETE FROM taste_profile_artists WHERE user_id = $1; + +-- name: InsertTasteProfileArtist :exec +INSERT INTO taste_profile_artists (user_id, artist_id, weight) +VALUES ($1, $2, $3); + +-- name: DeleteTasteProfileTagsForUser :exec +DELETE FROM taste_profile_tags WHERE user_id = $1; + +-- name: InsertTasteProfileTag :exec +INSERT INTO taste_profile_tags (user_id, tag, weight) +VALUES ($1, $2, $3); + +-- name: ListTasteProfileArtistsForUser :many +-- Top-weighted taste artists (phase-2 consumption + tests). +SELECT artist_id, weight +FROM taste_profile_artists +WHERE user_id = $1 +ORDER BY weight DESC +LIMIT $2; + +-- name: ListTasteProfileTagsForUser :many +SELECT tag, weight +FROM taste_profile_tags +WHERE user_id = $1 +ORDER BY weight DESC +LIMIT $2; diff --git a/internal/db/queries/you_might_like.sql b/internal/db/queries/you_might_like.sql new file mode 100644 index 00000000..c6038d5a --- /dev/null +++ b/internal/db/queries/you_might_like.sql @@ -0,0 +1,66 @@ +-- "You might like" Home rows (#790). The daily build computes ranked +-- album/artist IDs in Go (similarity roll-up + cold-start gate) and +-- atomic-replaces them here; /api/home reads them back hydrated. + +-- name: CountListeningSignalForUser :one +-- Cold-start gate. Returns the breadth of the user's real listening: +-- distinct unskipped tracks played and distinct artists played. The +-- build skips You-might-like entirely below a threshold (a thin history +-- yields near-random roll-ups). was_skipped=false so a user spamming +-- next can't inflate the signal. +SELECT + count(DISTINCT pe.track_id)::bigint AS distinct_tracks, + count(DISTINCT t.artist_id)::bigint AS distinct_artists +FROM play_events pe +JOIN tracks t ON t.id = pe.track_id +WHERE pe.user_id = $1 AND pe.was_skipped = false; + +-- name: DeleteYouMightLikeAlbumsForUser :exec +DELETE FROM you_might_like_albums WHERE user_id = $1; + +-- name: InsertYouMightLikeAlbum :exec +INSERT INTO you_might_like_albums (user_id, album_id, rank) +VALUES ($1, $2, $3); + +-- name: DeleteYouMightLikeArtistsForUser :exec +DELETE FROM you_might_like_artists WHERE user_id = $1; + +-- name: InsertYouMightLikeArtist :exec +INSERT INTO you_might_like_artists (user_id, artist_id, rank) +VALUES ($1, $2, $3); + +-- name: ListYouMightLikeAlbumsForUser :many +-- Read path. Same projection as ListRediscoverAlbumsForUser so the API +-- layer reuses albumRefFrom(row.Album, row.ArtistName, …). Ordered by +-- the rank persisted at build time. Final cross-section dedup (vs Most +-- Played / Rediscover) and the output cap land in the Go layer +-- (internal/recommendation/home.go). +SELECT sqlc.embed(albums), artists.name AS artist_name +FROM you_might_like_albums yml +JOIN albums ON albums.id = yml.album_id +JOIN artists ON artists.id = albums.artist_id +WHERE yml.user_id = $1 +ORDER BY yml.rank +LIMIT $2; + +-- name: ListYouMightLikeArtistsForUser :many +-- Read path. Same projection as ListRediscoverArtistsForUser (embeds the +-- artist + a representative cover_album_id + album_count) so the API +-- layer reuses artistRefFromCovered. Ordered by persisted rank. +SELECT sqlc.embed(artists), + cov.id AS cover_album_id, + cnt.album_count::bigint AS album_count +FROM you_might_like_artists yml +JOIN artists ON artists.id = yml.artist_id +LEFT JOIN LATERAL ( + SELECT id FROM albums + WHERE artist_id = artists.id AND cover_art_path IS NOT NULL + ORDER BY created_at DESC LIMIT 1 +) cov ON true +LEFT JOIN LATERAL ( + SELECT count(*) AS album_count + FROM albums WHERE artist_id = artists.id +) cnt ON true +WHERE yml.user_id = $1 +ORDER BY yml.rank +LIMIT $2; diff --git a/internal/playlists/scheduler.go b/internal/playlists/scheduler.go index 96376584..6d325378 100644 --- a/internal/playlists/scheduler.go +++ b/internal/playlists/scheduler.go @@ -28,6 +28,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/taste" ) // dailyRebuildHour is the local-time hour at which each user's system @@ -158,10 +159,7 @@ func (s *Scheduler) registerLocked(ctx context.Context, userID pgtype.UUID, tz s job, err := s.gocron.NewJob( gocron.CronJob(cronExpr, false /* withSeconds */), gocron.NewTask(func() { - if err := BuildSystemPlaylists(ctx, s.pool, s.logger, userID, time.Now(), s.dataDir); err != nil { - s.logger.Warn("scheduler: build failed", - "user_id", uuidStringPL(userID), "err", err) - } + s.rebuildUserDaily(ctx, userID, time.Now()) }), ) if err != nil { @@ -220,10 +218,22 @@ func (s *Scheduler) reconcile(ctx context.Context) { // whose scheduled fire was missed during downtime get caught up. func (s *Scheduler) runStartupCatchUp(ctx context.Context, users []dbq.ListActiveUsersWithTimezonesRow) { for _, u := range users { - if err := BuildSystemPlaylists(ctx, s.pool, s.logger, u.ID, time.Now(), s.dataDir); err != nil { - s.logger.Warn("scheduler: startup catch-up build failed", - "user_id", uuidStringPL(u.ID), "err", err) - } + s.rebuildUserDaily(ctx, u.ID, time.Now()) + } +} + +// rebuildUserDaily recomputes the user's taste profile, then rebuilds their +// system playlists + You-might-like rows. Taste runs first so the playlist +// build can read a fresh profile (phase-2 consumption); both steps are +// best-effort and a failure in one is logged without blocking the other. +func (s *Scheduler) rebuildUserDaily(ctx context.Context, userID pgtype.UUID, now time.Time) { + if err := taste.BuildTasteProfile(ctx, s.pool, s.logger, userID, taste.DefaultConfig()); err != nil { + s.logger.Warn("scheduler: taste profile rebuild failed", + "user_id", uuidStringPL(userID), "err", err) + } + if err := BuildSystemPlaylists(ctx, s.pool, s.logger, userID, now, s.dataDir); err != nil { + s.logger.Warn("scheduler: build failed", + "user_id", uuidStringPL(userID), "err", err) } } diff --git a/internal/playlists/system.go b/internal/playlists/system.go index b2eeead5..1efdba40 100644 --- a/internal/playlists/system.go +++ b/internal/playlists/system.go @@ -5,6 +5,7 @@ package playlists import ( + "bytes" "context" "crypto/sha256" "encoding/binary" @@ -147,6 +148,11 @@ var systemMixWeights = recommendation.ScoringWeights{ JitterMagnitude: 0.1, ContextWeight: 0.5, SimilarityWeight: 1.5, + // Taste profile (#796 phase 2): the daily mixes are the primary + // taste-driven surface, so they lean on it. TasteMatchScore is in + // [-1,+1], so 1.5 makes a strong taste fit comparable to a like boost + // while passive avoidance (negative) gently demotes. + TasteWeight: 1.5, } // forYouHeadN is the number of top-scored tracks that anchor the For-You @@ -180,8 +186,21 @@ func scoreAndSortCandidates(cands []recommendation.Candidate, userID pgtype.UUID c recommendation.Candidate score float64 } - pairs := make([]scored, len(cands)) - for i, c := range cands { + // Pin candidate order by track id before drawing jitter. The candidate + // query (LoadRadioCandidatesV2) has ORDER BY random() arms and no + // stable outer ordering, so DB row order varies call-to-call. Since + // the i-th candidate gets the i-th seeded jitter draw, an unstable + // input order would assign different jitter to the same track across + // same-day rebuilds and reorder near-ties — breaking the daily + // determinism this function promises. Sorting by id first makes the + // jitter assignment a function of (track, day) alone. + ordered := make([]recommendation.Candidate, len(cands)) + copy(ordered, cands) + sort.SliceStable(ordered, func(i, j int) bool { + return uuidLessPL(ordered[i].Track.ID, ordered[j].Track.ID) + }) + pairs := make([]scored, len(ordered)) + for i, c := range ordered { pairs[i] = scored{c: c, score: recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64)} } sort.SliceStable(pairs, func(i, j int) bool { @@ -497,6 +516,13 @@ func BuildSystemPlaylists(ctx context.Context, pool *pgxpool.Pool, logger *slog. built = append(built, out...) } + // "You might like" Home rows reuse the same similarity engine but + // persist to dedicated album/artist tables (not playlist_tracks). + // Computed here (reads only) and atomic-replaced inside the tx below, + // alongside the system playlists. A failed/gated computation is + // handled by yml.built (leave prior rows vs. clear) — never fatal. + yml := buildYouMightLike(ctx, q, logger, userID, dateStr, now) + // Atomic replace inside a transaction. tx, err := pool.Begin(ctx) if err != nil { @@ -530,6 +556,13 @@ func BuildSystemPlaylists(ctx context.Context, pool *pgxpool.Pool, logger *slog. createdIDs = append(createdIDs, id) } + if yml.built { + if err := persistYouMightLike(ctx, qtx, userID, yml); err != nil { + buildErr = fmt.Errorf("persist you-might-like: %w", err) + return buildErr + } + } + if err := tx.Commit(ctx); err != nil { buildErr = fmt.Errorf("commit tx: %w", err) return buildErr @@ -695,6 +728,12 @@ func insertSystemPlaylist(ctx context.Context, qtx *dbq.Queries, userID pgtype.U // (pgtype.UUID's String method exists on some pgx versions but not others; // also the playlists package needs this independent of test helpers.) // Suffix `PL` distinguishes it from any test helper named uuidString. +// uuidLessPL reports whether a sorts before b by raw 16-byte value. Used +// to pin candidate order deterministically before jitter assignment. +func uuidLessPL(a, b pgtype.UUID) bool { + return bytes.Compare(a.Bytes[:], b.Bytes[:]) < 0 +} + func uuidStringPL(u pgtype.UUID) string { if !u.Valid { return "" diff --git a/internal/playlists/you_might_like.go b/internal/playlists/you_might_like.go new file mode 100644 index 00000000..e2d8b47e --- /dev/null +++ b/internal/playlists/you_might_like.go @@ -0,0 +1,227 @@ +// 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} + cands, err := recommendation.LoadCandidatesFromSimilarity( + ctx, q, userID, seed, 1, zeroVec, + []pgtype.UUID{seed}, systemForYouSourceLimits(), + ) + 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 +} diff --git a/internal/playlists/you_might_like_db_test.go b/internal/playlists/you_might_like_db_test.go new file mode 100644 index 00000000..05e91df8 --- /dev/null +++ b/internal/playlists/you_might_like_db_test.go @@ -0,0 +1,99 @@ +package playlists_test + +import ( + "context" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/playlists" +) + +// countYouMightLike returns the persisted row counts for a user. +func countYouMightLike(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID) (albums, artists int) { + t.Helper() + ctx := context.Background() + if err := pool.QueryRow(ctx, + `SELECT count(*) FROM you_might_like_albums WHERE user_id = $1`, userID, + ).Scan(&albums); err != nil { + t.Fatalf("count albums: %v", err) + } + if err := pool.QueryRow(ctx, + `SELECT count(*) FROM you_might_like_artists WHERE user_id = $1`, userID, + ).Scan(&artists); err != nil { + t.Fatalf("count artists: %v", err) + } + return albums, artists +} + +// TestYouMightLike_ColdStartGate: a thin profile (3 artists × 3 tracks = +// 9 distinct tracks, below the 20-track / 5-artist gate) must produce no +// You-might-like rows — the build ships nothing rather than near-random +// tiles. +func TestYouMightLike_ColdStartGate(t *testing.T) { + pool := newPool(t) + logger := discardLogger() + u, _ := seedActiveLibrary(t, pool, "ymlcold", 3, 3) + + if err := playlists.BuildSystemPlaylists( + context.Background(), pool, logger, u.ID, time.Now().UTC(), t.TempDir(), + ); err != nil { + t.Fatalf("build: %v", err) + } + + albums, artists := countYouMightLike(t, pool, u.ID) + if albums != 0 || artists != 0 { + t.Errorf("cold-start user should have no you-might-like rows; got %d albums, %d artists", + albums, artists) + } +} + +// TestYouMightLike_SufficientActivityPopulates: a rich profile (8 artists +// × 4 tracks = 32 distinct tracks, clearing both gate bars) populates the +// You-might-like tables in the daily build. +func TestYouMightLike_SufficientActivityPopulates(t *testing.T) { + pool := newPool(t) + logger := discardLogger() + u, _ := seedActiveLibrary(t, pool, "ymlrich", 8, 4) + + if err := playlists.BuildSystemPlaylists( + context.Background(), pool, logger, u.ID, time.Now().UTC(), t.TempDir(), + ); err != nil { + t.Fatalf("build: %v", err) + } + + albums, artists := countYouMightLike(t, pool, u.ID) + if albums == 0 { + t.Error("rich user should have you-might-like album rows; got 0") + } + if artists == 0 { + t.Error("rich user should have you-might-like artist rows; got 0") + } +} + +// TestYouMightLike_AtomicReplace: a second build replaces rather than +// appends, so counts stay bounded by the persisted depth. +func TestYouMightLike_AtomicReplace(t *testing.T) { + pool := newPool(t) + logger := discardLogger() + u, _ := seedActiveLibrary(t, pool, "ymlreplace", 8, 4) + ctx := context.Background() + + for i := 0; i < 2; i++ { + if err := playlists.BuildSystemPlaylists( + ctx, pool, logger, u.ID, time.Now().UTC(), t.TempDir(), + ); err != nil { + t.Fatalf("build %d: %v", i, err) + } + } + + albums, artists := countYouMightLike(t, pool, u.ID) + if albums > 12 { + t.Errorf("albums = %d after two builds, want <= 12 (atomic replace)", albums) + } + if artists > 12 { + t.Errorf("artists = %d after two builds, want <= 12 (atomic replace)", artists) + } +} diff --git a/internal/playlists/you_might_like_test.go b/internal/playlists/you_might_like_test.go new file mode 100644 index 00000000..ffe390a4 --- /dev/null +++ b/internal/playlists/you_might_like_test.go @@ -0,0 +1,125 @@ +package playlists + +import ( + "testing" + "time" + + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" +) + +// fixedNow keeps recencyDecay stable across the pure roll-up tests. +var fixedNow = time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC) + +// byteOf returns the per-test entity discriminator (makeCand sets it in +// Bytes[15]) so assertions can name albums/artists by their small int. +func byteOf(u pgtype.UUID) byte { return u.Bytes[15] } + +// countWithByte reports how many of ids carry the given Bytes[15] marker. +func countWithByte(ids []pgtype.UUID, want byte) int { + n := 0 + for _, id := range ids { + if byteOf(id) == want { + n++ + } + } + return n +} + +func TestSumTopK(t *testing.T) { + if got := sumTopK([]float64{1, 2, 3, 4}, 2); got != 7 { + t.Errorf("sumTopK top-2 of 1..4 = %v, want 7", got) + } + if got := sumTopK([]float64{5}, 3); got != 5 { + t.Errorf("sumTopK fewer-than-k = %v, want 5", got) + } + if got := sumTopK(nil, 3); got != 0 { + t.Errorf("sumTopK empty = %v, want 0", got) + } +} + +func TestRollUpCandidates_MultiMatchAlbumRanksFirst(t *testing.T) { + // Album 10 (artist 100): three solid matches. Album 20 (artist 200): + // one slightly-stronger single match. Sum-of-top-3 should rank the + // multi-match album ahead of the single-match one. Similarity gaps are + // wide enough that the ±0.1 jitter can't reorder the result. + cands := []recommendation.Candidate{ + makeCand(1, 10, 100, 0.8), + makeCand(2, 10, 100, 0.8), + makeCand(3, 10, 100, 0.8), + makeCand(4, 20, 200, 0.95), + } + albums, _ := rollUpCandidates(cands, testUserID, "2026-06-11", fixedNow) + if len(albums) < 2 { + t.Fatalf("want >=2 albums, got %d", len(albums)) + } + if byteOf(albums[0]) != 10 { + t.Errorf("top album = %d, want 10 (multi-match beats single)", byteOf(albums[0])) + } +} + +func TestRollUpCandidates_PerArtistAlbumCap(t *testing.T) { + // Artist 100 spans albums 10/11/12; the album row caps any one artist + // at youMightLikeMaxAlbumsPerArtist (2). Artist 200's album 20 should + // still appear. + cands := []recommendation.Candidate{ + makeCand(1, 10, 100, 0.9), + makeCand(2, 11, 100, 0.85), + makeCand(3, 12, 100, 0.8), + makeCand(4, 20, 200, 0.7), + } + albums, _ := rollUpCandidates(cands, testUserID, "2026-06-11", fixedNow) + from100 := countWithByte(albums, 10) + countWithByte(albums, 11) + countWithByte(albums, 12) + if from100 > youMightLikeMaxAlbumsPerArtist { + t.Errorf("artist 100 contributed %d albums, want <= %d", + from100, youMightLikeMaxAlbumsPerArtist) + } + if countWithByte(albums, 20) != 1 { + t.Errorf("album 20 (artist 200) should survive the cap; got %d", + countWithByte(albums, 20)) + } +} + +func TestRollUpCandidates_ArtistRollupDistinct(t *testing.T) { + // Three artists; the artist roll-up should surface all three distinct + // artist IDs (no per-artist cap on the artist row). + cands := []recommendation.Candidate{ + makeCand(1, 10, 100, 0.9), + makeCand(2, 11, 101, 0.6), + makeCand(3, 12, 102, 0.3), + } + _, artists := rollUpCandidates(cands, testUserID, "2026-06-11", fixedNow) + seen := map[byte]bool{} + for _, a := range artists { + seen[byteOf(a)] = true + } + for _, want := range []byte{100, 101, 102} { + if !seen[want] { + t.Errorf("artist %d missing from roll-up", want) + } + } +} + +func TestCapAlbumsPerArtist_DropsBeyondCap(t *testing.T) { + mk := func(albumN, artistN int) (pgtype.UUID, pgtype.UUID) { + var al, ar pgtype.UUID + al.Valid, ar.Valid = true, true + al.Bytes[15], ar.Bytes[15] = byte(albumN), byte(artistN) + return al, ar + } + a10, ar1 := mk(10, 1) + a11, _ := mk(11, 1) + a12, _ := mk(12, 1) + a20, ar2 := mk(20, 2) + albumArtist := map[pgtype.UUID]pgtype.UUID{a10: ar1, a11: ar1, a12: ar1, a20: ar2} + in := []scoredEntity{{a10, 3}, {a11, 2}, {a12, 1}, {a20, 0.5}} + got := capAlbumsPerArtist(in, albumArtist, 2) + if len(got) != 3 { + t.Fatalf("len = %d, want 3 (artist 1 capped at 2 + artist 2's one)", len(got)) + } + // Highest-scored two of artist 1 (albums 10, 11) kept; 12 dropped. + if byteOf(got[2].id) != 20 { + t.Errorf("third survivor = %d, want 20", byteOf(got[2].id)) + } +} diff --git a/internal/recommendation/candidates.go b/internal/recommendation/candidates.go index 6c30cb21..ad0d6d39 100644 --- a/internal/recommendation/candidates.go +++ b/internal/recommendation/candidates.go @@ -36,6 +36,11 @@ func LoadCandidates( return nil, err } + profile, err := LoadTasteProfile(ctx, q, userID) + if err != nil { + return nil, err + } + out := make([]Candidate, 0, len(rows)) for _, r := range rows { var lpt *time.Time @@ -52,6 +57,7 @@ func LoadCandidates( PlayCount: int(r.PlayCount), SkipCount: int(r.SkipCount), ContextualMatchScore: ctxScore, + TasteMatchScore: profile.Match(r.Track.ArtistID, r.Track.Genre), }, }) } @@ -117,6 +123,11 @@ func LoadCandidatesFromSimilarity( return nil, err } + profile, err := LoadTasteProfile(ctx, q, userID) + if err != nil { + return nil, err + } + out := make([]Candidate, 0, len(rows)) for _, r := range rows { var lpt *time.Time @@ -141,6 +152,7 @@ func LoadCandidatesFromSimilarity( SkipCount: int(r.SkipCount), ContextualMatchScore: ctxScore, SimilarityScore: simScore, + TasteMatchScore: profile.Match(r.Track.ArtistID, r.Track.Genre), }, }) } diff --git a/internal/recommendation/home.go b/internal/recommendation/home.go index a731f833..7f380139 100644 --- a/internal/recommendation/home.go +++ b/internal/recommendation/home.go @@ -34,6 +34,13 @@ const ( // artist from dominating the row. Two is enough variety; higher // reads as "this artist's discography" instead of "rediscover". maxAlbumsPerArtistInRediscover = 2 + + // HomeYouMightLikeLimit is the rendered count for each You-might-like + // row. The build persists a few more (youMightLikeAlbumsN/ArtistsN) + // so the read-time cross-section dedup has headroom; this query asks + // for the persisted depth and the Go layer trims to this after dedup. + HomeYouMightLikeLimit = 10 + youMightLikeFetch = 12 ) // HomePayload is the composite returned by HomeData. All slices are @@ -45,6 +52,8 @@ type HomePayload struct { RediscoverArtists []dbq.ListRediscoverArtistsForUserRow MostPlayedTracks []dbq.ListMostPlayedTracksForUserRow LastPlayedArtists []dbq.ListLastPlayedArtistsForUserRow + YouMightLikeAlbums []dbq.ListYouMightLikeAlbumsForUserRow + YouMightLikeArtists []dbq.ListYouMightLikeArtistsForUserRow } // HomeData runs five queries in parallel and assembles the payload. @@ -77,9 +86,11 @@ func HomeData(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID) (*Hom RediscoverArtists: []dbq.ListRediscoverArtistsForUserRow{}, MostPlayedTracks: []dbq.ListMostPlayedTracksForUserRow{}, LastPlayedArtists: []dbq.ListLastPlayedArtistsForUserRow{}, + YouMightLikeAlbums: []dbq.ListYouMightLikeAlbumsForUserRow{}, + YouMightLikeArtists: []dbq.ListYouMightLikeArtistsForUserRow{}, } - wg.Add(5) + wg.Add(7) go func() { defer wg.Done() rows, err := q.ListRecentlyAddedAlbumsWithArtist(ctx, HomeRecentlyAddedLimit) @@ -129,6 +140,28 @@ func HomeData(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID) (*Hom } out.RediscoverArtists = rows }() + go func() { + defer wg.Done() + rows, err := q.ListYouMightLikeAlbumsForUser(ctx, dbq.ListYouMightLikeAlbumsForUserParams{ + UserID: userID, Limit: youMightLikeFetch, + }) + if err != nil { + fail("you_might_like_albums", err) + return + } + out.YouMightLikeAlbums = rows + }() + go func() { + defer wg.Done() + rows, err := q.ListYouMightLikeArtistsForUser(ctx, dbq.ListYouMightLikeArtistsForUserParams{ + UserID: userID, Limit: youMightLikeFetch, + }) + if err != nil { + fail("you_might_like_artists", err) + return + } + out.YouMightLikeArtists = rows + }() wg.Wait() if firstErr != nil { @@ -140,9 +173,70 @@ func HomeData(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID) (*Hom // the rediscover lists down toward HomeRediscoverLimit. out.RediscoverAlbums = applyRediscoverAlbumFilters(out.RediscoverAlbums, out.MostPlayedTracks) out.RediscoverArtists = applyRediscoverArtistFilters(out.RediscoverArtists, out.MostPlayedTracks) + // You-might-like dedups against what the user already engages with + // (Most Played) and against the other Home rows so a tile doesn't + // appear twice. Trims to HomeYouMightLikeLimit after dedup. + out.YouMightLikeAlbums = applyYouMightLikeAlbumFilters( + out.YouMightLikeAlbums, out.MostPlayedTracks, out.RediscoverAlbums) + out.YouMightLikeArtists = applyYouMightLikeArtistFilters( + out.YouMightLikeArtists, out.MostPlayedTracks, out.RediscoverArtists, out.LastPlayedArtists) return out, nil } +// applyYouMightLikeAlbumFilters drops albums the user actively plays +// (Most Played) or that already appear in Rediscover, then trims to +// HomeYouMightLikeLimit. Build-time rank order is preserved. +func applyYouMightLikeAlbumFilters( + rows []dbq.ListYouMightLikeAlbumsForUserRow, + mostPlayed []dbq.ListMostPlayedTracksForUserRow, + rediscover []dbq.ListRediscoverAlbumsForUserRow, +) []dbq.ListYouMightLikeAlbumsForUserRow { + excluded := mostPlayedAlbumIDs(mostPlayed) + for _, r := range rediscover { + excluded[r.Album.ID] = struct{}{} + } + out := make([]dbq.ListYouMightLikeAlbumsForUserRow, 0, HomeYouMightLikeLimit) + for _, r := range rows { + if _, dup := excluded[r.Album.ID]; dup { + continue + } + out = append(out, r) + if len(out) >= HomeYouMightLikeLimit { + break + } + } + return out +} + +// applyYouMightLikeArtistFilters drops artists the user actively plays +// (Most Played), recently played (Last Played), or that already appear in +// Rediscover, then trims to HomeYouMightLikeLimit. +func applyYouMightLikeArtistFilters( + rows []dbq.ListYouMightLikeArtistsForUserRow, + mostPlayed []dbq.ListMostPlayedTracksForUserRow, + rediscover []dbq.ListRediscoverArtistsForUserRow, + lastPlayed []dbq.ListLastPlayedArtistsForUserRow, +) []dbq.ListYouMightLikeArtistsForUserRow { + excluded := mostPlayedArtistIDs(mostPlayed) + for _, r := range rediscover { + excluded[r.Artist.ID] = struct{}{} + } + for _, r := range lastPlayed { + excluded[r.Artist.ID] = struct{}{} + } + out := make([]dbq.ListYouMightLikeArtistsForUserRow, 0, HomeYouMightLikeLimit) + for _, r := range rows { + if _, dup := excluded[r.Artist.ID]; dup { + continue + } + out = append(out, r) + if len(out) >= HomeYouMightLikeLimit { + break + } + } + return out +} + // mostPlayedAlbumIDs returns the set of album IDs whose tracks appear // in the Most Played row. Used to dedup Rediscover so it doesn't list // albums the user is actively spinning. diff --git a/internal/recommendation/score.go b/internal/recommendation/score.go index 3c6f061e..c3b9a9ef 100644 --- a/internal/recommendation/score.go +++ b/internal/recommendation/score.go @@ -19,6 +19,11 @@ type ScoringInputs struct { SkipCount int // play_events with was_skipped=true ContextualMatchScore float64 // [0, 1]; 0 when no signal SimilarityScore float64 // [0, 1]; 0 when no signal (random fill) + // TasteMatchScore is the candidate's fit to the user's learned taste + // profile (#796 phase 2), in [-1, +1]: positive draws a track toward the + // user's taste, negative reflects passive avoidance, 0 when there's no + // profile signal (cold start / artist+tags absent from the profile). + TasteMatchScore float64 } // ScoringWeights are the operator-tunable knobs. Defaults live in @@ -31,6 +36,7 @@ type ScoringWeights struct { JitterMagnitude float64 ContextWeight float64 SimilarityWeight float64 + TasteWeight float64 } // Score computes the weighted-shuffle score per spec §6: @@ -41,6 +47,7 @@ type ScoringWeights struct { // - skip_ratio * SkipPenalty // + contextual_match_score * ContextWeight // + similarity_score * SimilarityWeight +// + taste_match_score * TasteWeight // + small_random_jitter // // Higher score = more likely to surface. rng is a function returning a @@ -55,6 +62,7 @@ func Score(in ScoringInputs, w ScoringWeights, now time.Time, rng func() float64 s -= skipRatio(in.PlayCount, in.SkipCount) * w.SkipPenalty s += in.ContextualMatchScore * w.ContextWeight s += in.SimilarityScore * w.SimilarityWeight + s += in.TasteMatchScore * w.TasteWeight s += (rng()*2 - 1) * w.JitterMagnitude return s } diff --git a/internal/recommendation/taste.go b/internal/recommendation/taste.go new file mode 100644 index 00000000..0953b01c --- /dev/null +++ b/internal/recommendation/taste.go @@ -0,0 +1,91 @@ +package recommendation + +import ( + "context" + "math" + + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// Taste-match tuning. The taste profile (written by internal/taste) holds +// signed, unbounded artist/tag weights; these scales squash them into a +// bounded [-1, +1] match via tanh, so one outlier artist can't compress the +// rest toward zero (as a per-user max-normalisation would). A weight at the +// scale value maps to tanh(1) ≈ 0.76 — "clearly a preference." +const ( + tasteArtistScale = 4.0 + tasteTagScale = 3.0 + tasteArtistShare = 0.7 + tasteTagShare = 0.3 + tasteProfileLimit = 2000 // read cap; profiles are size-capped on write +) + +// TasteProfile is the read-side view of a user's learned taste: signed +// weights over artists and genre tags. The zero value (and any unknown +// artist/tag) contributes 0, so cold-start users get no taste effect. +type TasteProfile struct { + artists map[pgtype.UUID]float64 + tags map[string]float64 +} + +// LoadTasteProfile reads the user's taste profile from the taste_profile_* +// tables (written daily by internal/taste). Returns an empty profile with no +// error when the user has none. +func LoadTasteProfile(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) (TasteProfile, error) { + arts, err := q.ListTasteProfileArtistsForUser(ctx, dbq.ListTasteProfileArtistsForUserParams{ + UserID: userID, Limit: tasteProfileLimit, + }) + if err != nil { + return TasteProfile{}, err + } + tags, err := q.ListTasteProfileTagsForUser(ctx, dbq.ListTasteProfileTagsForUserParams{ + UserID: userID, Limit: tasteProfileLimit, + }) + if err != nil { + return TasteProfile{}, err + } + p := TasteProfile{ + artists: make(map[pgtype.UUID]float64, len(arts)), + tags: make(map[string]float64, len(tags)), + } + for _, a := range arts { + p.artists[a.ArtistID] = a.Weight + } + for _, t := range tags { + p.tags[t.Tag] = t.Weight + } + return p, nil +} + +// Match scores a candidate track's fit to the profile in [-1, +1]: a blend of +// the artist's taste weight and the average of its genre tags' weights, each +// tanh-squashed. Absent artist/tags contribute 0. +func (p TasteProfile) Match(artistID pgtype.UUID, genre *string) float64 { + a := math.Tanh(p.artists[artistID] / tasteArtistScale) + + var tg float64 + if genre != nil { + tags := splitGenres(*genre) + if len(tags) > 0 { + var sum float64 + for _, t := range tags { + sum += p.tags[t] + } + tg = math.Tanh((sum / float64(len(tags))) / tasteTagScale) + } + } + return clampUnit(tasteArtistShare*a + tasteTagShare*tg) +} + +// clampUnit constrains x to [-1, 1]. +func clampUnit(x float64) float64 { + if x < -1 { + return -1 + } + if x > 1 { + return 1 + } + return x +} diff --git a/internal/recommendation/taste_test.go b/internal/recommendation/taste_test.go new file mode 100644 index 00000000..408e894a --- /dev/null +++ b/internal/recommendation/taste_test.go @@ -0,0 +1,109 @@ +package recommendation + +import ( + "context" + "math" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +func uuidN(n byte) pgtype.UUID { + return pgtype.UUID{Bytes: [16]byte{15: n}, Valid: true} +} + +func strPtr(s string) *string { return &s } + +func TestTasteProfile_Match(t *testing.T) { + loved := uuidN(1) + disliked := uuidN(2) + unknown := uuidN(3) + p := TasteProfile{ + artists: map[pgtype.UUID]float64{loved: 8.0, disliked: -8.0}, + tags: map[string]float64{"Jazz": 6.0, "Noise": -6.0}, + } + + if m := p.Match(loved, strPtr("Jazz")); m <= 0.5 { + t.Errorf("loved artist + loved tag = %.3f, want strongly positive", m) + } + if m := p.Match(disliked, strPtr("Noise")); m >= -0.5 { + t.Errorf("disliked artist + disliked tag = %.3f, want strongly negative", m) + } + if m := p.Match(unknown, nil); m != 0 { + t.Errorf("unknown artist, no genre = %.3f, want 0", m) + } + // Artist dominates (0.7 share): loved artist with an unknown tag is still + // clearly positive. + if m := p.Match(loved, strPtr("Unheard")); m <= 0 { + t.Errorf("loved artist + unknown tag = %.3f, want positive", m) + } + // Output stays within [-1, 1] even with saturated inputs. + for _, a := range []pgtype.UUID{loved, disliked, unknown} { + m := p.Match(a, strPtr("Jazz")) + if m < -1 || m > 1 { + t.Errorf("Match out of [-1,1]: %.3f", m) + } + } +} + +func TestTasteProfile_EmptyIsNeutral(t *testing.T) { + var p TasteProfile // zero value: nil maps + if m := p.Match(uuidN(1), strPtr("Jazz")); m != 0 { + t.Errorf("empty profile Match = %.3f, want 0 (cold start neutral)", m) + } +} + +func TestScore_TasteTermAddsAndSubtracts(t *testing.T) { + now := time.Now() + zeroJitter := func() float64 { return 0.5 } // (0.5*2-1)=0 with any magnitude + w := ScoringWeights{TasteWeight: 2.0} // all other weights 0 + + pos := Score(ScoringInputs{TasteMatchScore: 1.0}, w, now, zeroJitter) + if !almostEq(pos, 2.0) { + t.Errorf("positive taste: Score = %.3f, want 2.0", pos) + } + neg := Score(ScoringInputs{TasteMatchScore: -1.0}, w, now, zeroJitter) + if !almostEq(neg, -2.0) { + t.Errorf("negative taste: Score = %.3f, want -2.0 (demotes)", neg) + } + off := Score(ScoringInputs{TasteMatchScore: 1.0}, ScoringWeights{}, now, zeroJitter) + if !almostEq(off, 0.0) { + t.Errorf("TasteWeight 0: Score = %.3f, want 0 (no effect)", off) + } +} + +func almostEq(a, b float64) bool { return math.Abs(a-b) < 1e-9 } + +// TestLoadTasteProfile_RoundTrip seeds taste_profile rows and verifies the +// reader hydrates them into a profile that scores a matching track positively. +func TestLoadTasteProfile_RoundTrip(t *testing.T) { + pool := newPool(t) + ctx := context.Background() + u := seedUser(t, pool, "taste-rt") + art := seedArtist(t, pool, "Loved Artist", "") + + if _, err := pool.Exec(ctx, + `INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, $3)`, + u.ID, art.ID, 8.0); err != nil { + t.Fatalf("seed taste artist: %v", err) + } + if _, err := pool.Exec(ctx, + `INSERT INTO taste_profile_tags (user_id, tag, weight) VALUES ($1, $2, $3)`, + u.ID, "Jazz", 6.0); err != nil { + t.Fatalf("seed taste tag: %v", err) + } + + p, err := LoadTasteProfile(ctx, dbq.New(pool), u.ID) + if err != nil { + t.Fatalf("load: %v", err) + } + if m := p.Match(art.ID, strPtr("Jazz")); m <= 0.5 { + t.Errorf("round-trip Match = %.3f, want strongly positive", m) + } + if m := p.Match(uuidN(9), nil); m != 0 { + t.Errorf("absent artist Match = %.3f, want 0", m) + } +} diff --git a/internal/taste/engagement.go b/internal/taste/engagement.go new file mode 100644 index 00000000..40bcab7d --- /dev/null +++ b/internal/taste/engagement.go @@ -0,0 +1,81 @@ +// Package taste builds a persistent, decaying per-user taste profile (#796). +// engagement.go is the pure core: it turns a single play's completion ratio +// into a graded taste signal in [-1, +1], replacing the binary was_skipped +// for learning purposes (was_skipped stays as-is for History display). +package taste + +import "math" + +// EngagementParams tune the completion→engagement curve. All in [0,1] and +// ordered HardSkip < NeutralCompletion < FullCompletion. +type EngagementParams struct { + // HardSkip: completion at/below which a play is a strong reject (-1). + HardSkip float64 + // NeutralCompletion: completion at which a play is neutral (0). Set low + // (~0.30) because people enjoy plenty of tracks they don't finish; only + // genuinely-early abandons read as negative. + NeutralCompletion float64 + // FullCompletion: completion at/above which a play is a full positive + // (+1). + FullCompletion float64 +} + +// DefaultEngagementParams returns the tuned starting curve. +func DefaultEngagementParams() EngagementParams { + return EngagementParams{HardSkip: 0.05, NeutralCompletion: 0.30, FullCompletion: 0.90} +} + +// Engagement maps a play's completion ratio to a taste signal in [-1, +1] +// via two linear ramps meeting at the neutral point: +// +// completion <= HardSkip → -1 +// HardSkip < c < Neutral → ramp -1 → 0 +// Neutral <= c < Full → ramp 0 → +1 +// completion >= Full → +1 +// +// Completion is clamped to [0,1]. Degenerate params (non-increasing +// thresholds) collapse the affected ramp to its endpoint rather than +// dividing by zero. +func Engagement(completion float64, p EngagementParams) float64 { + c := clamp01(completion) + if c <= p.HardSkip { + return -1 + } + if c < p.NeutralCompletion { + span := p.NeutralCompletion - p.HardSkip + if span <= 0 { + return 0 + } + return -1 + (c-p.HardSkip)/span + } + if c < p.FullCompletion { + span := p.FullCompletion - p.NeutralCompletion + if span <= 0 { + return 1 + } + return (c - p.NeutralCompletion) / span + } + return 1 +} + +// decay is the exponential time-decay applied to a play's engagement: +// 1.0 at age 0, 0.5 at age == halfLifeDays. Negative ages clamp to 0. +func decay(ageDays, halfLifeDays float64) float64 { + if ageDays < 0 { + ageDays = 0 + } + if halfLifeDays <= 0 { + return 1 + } + return math.Exp(-math.Ln2 * ageDays / halfLifeDays) +} + +func clamp01(x float64) float64 { + if x < 0 { + return 0 + } + if x > 1 { + return 1 + } + return x +} diff --git a/internal/taste/engagement_test.go b/internal/taste/engagement_test.go new file mode 100644 index 00000000..38c1c48e --- /dev/null +++ b/internal/taste/engagement_test.go @@ -0,0 +1,75 @@ +package taste + +import ( + "math" + "testing" +) + +func almostEqual(a, b float64) bool { return math.Abs(a-b) < 1e-9 } + +func TestEngagement_Curve(t *testing.T) { + p := DefaultEngagementParams() // HardSkip 0.05, Neutral 0.30, Full 0.90 + cases := []struct { + name string + completion float64 + want float64 + }{ + {"instant skip", 0.00, -1}, + {"at hard-skip boundary", 0.05, -1}, + {"neg ramp midpoint", 0.175, -0.5}, // (0.175-0.05)/(0.30-0.05)=0.5 → -0.5 + {"neutral point", 0.30, 0}, + {"pos ramp midpoint", 0.60, 0.5}, // (0.60-0.30)/(0.90-0.30)=0.5 + {"at full boundary", 0.90, 1}, + {"complete", 1.00, 1}, + {"clamp above 1", 1.50, 1}, + {"clamp below 0", -0.5, -1}, + } + for _, c := range cases { + if got := Engagement(c.completion, p); !almostEqual(got, c.want) { + t.Errorf("%s: Engagement(%.3f) = %.4f, want %.4f", c.name, c.completion, got, c.want) + } + } +} + +func TestEngagement_MonotonicNonDecreasing(t *testing.T) { + p := DefaultEngagementParams() + prev := math.Inf(-1) + for i := 0; i <= 100; i++ { + c := float64(i) / 100 + got := Engagement(c, p) + if got < prev-1e-9 { + t.Fatalf("engagement decreased at completion %.2f: %.4f < %.4f", c, got, prev) + } + prev = got + } +} + +func TestEngagement_DegenerateParamsStayInRange(t *testing.T) { + // Non-increasing thresholds must collapse ramps (no divide-by-zero) and + // still produce a value in [-1, 1]. + p := EngagementParams{HardSkip: 0.5, NeutralCompletion: 0.5, FullCompletion: 0.5} + for _, c := range []float64{0, 0.25, 0.5, 0.75, 1} { + got := Engagement(c, p) + if got < -1 || got > 1 { + t.Errorf("Engagement(%.2f) = %.3f out of [-1,1]", c, got) + } + } +} + +func TestDecay_HalfLife(t *testing.T) { + if got := decay(0, 75); !almostEqual(got, 1) { + t.Errorf("decay(0) = %.4f, want 1", got) + } + if got := decay(75, 75); !almostEqual(got, 0.5) { + t.Errorf("decay(halflife) = %.4f, want 0.5", got) + } + if got := decay(150, 75); !almostEqual(got, 0.25) { + t.Errorf("decay(2*halflife) = %.4f, want 0.25", got) + } + if got := decay(-5, 75); !almostEqual(got, 1) { + t.Errorf("decay(negative age) = %.4f, want 1 (clamped)", got) + } + if got := decay(10, 0); !almostEqual(got, 1) { + t.Errorf("decay(halflife=0) = %.4f, want 1 (guarded)", got) + } +} diff --git a/internal/taste/profile.go b/internal/taste/profile.go new file mode 100644 index 00000000..c9696bdf --- /dev/null +++ b/internal/taste/profile.go @@ -0,0 +1,267 @@ +// profile.go assembles the per-user taste profile from graded play +// engagement (decayed over time) plus explicit-like bonuses, and +// atomic-replaces the taste_profile_* tables. Phase 1 only builds the +// object; candidate sourcing + scoring consume it in phase 2. +package taste + +import ( + "bytes" + "context" + "fmt" + "log/slog" + "sort" + "strings" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// Config holds the operator-tunable knobs. DefaultConfig supplies tuned +// starting values; wiring these into the server RecommendationConfig is a +// later follow-up (phase 1 uses the defaults). +type Config struct { + Engagement EngagementParams + + // HalfLifeDays: a play's engagement halves every this-many days, so the + // profile tracks drift (recent behaviour dominates). WindowDays bounds + // the plays fetched — beyond ~3 half-lives the decayed contribution is + // negligible, so older plays are skipped for cost, not correctness. + HalfLifeDays float64 + WindowDays float64 + + // Explicit-like bonuses. Likes stay the strongest single positive. + ArtistLikeBonus float64 + TrackLikeBonus float64 + TagLikeBonus float64 + + // ArtistFloor / TagFloor clamp how negative a single entity's weight may + // go. Aggregation already protects an artist the user likes (one skip + // nets out against many good plays); the floor additionally bounds the + // magnitude so a heavily-skipped entity can't dominate as an extreme + // negative. + ArtistFloor float64 + TagFloor float64 + + // WeightEpsilon: entities with |weight| below this are dropped as noise. + WeightEpsilon float64 + + // Size caps (guard rails for pathological libraries). Persist the top + // MaxArtists by weight plus the most-negative MaxNegArtists; same for tags. + MaxArtists int + MaxNegArtists int + MaxTags int + MaxNegTags int +} + +// DefaultConfig returns the tuned starting configuration. +func DefaultConfig() Config { + return Config{ + Engagement: DefaultEngagementParams(), + HalfLifeDays: 75, + WindowDays: 270, + ArtistLikeBonus: 3.0, + TrackLikeBonus: 1.0, + TagLikeBonus: 0.5, + ArtistFloor: -3.0, + TagFloor: -3.0, + WeightEpsilon: 0.05, + MaxArtists: 500, + MaxNegArtists: 150, + MaxTags: 200, + MaxNegTags: 60, + } +} + +// BuildTasteProfile recomputes and atomic-replaces the user's taste profile. +// Reads the decay-windowed play engagement + explicit likes, accumulates +// signed artist/tag weights, applies floor damping + size caps, and persists. +// Self-contained transaction for the replace. Returns an error only on a +// fatal DB failure; the daily caller logs and proceeds to playlist building +// regardless (best-effort). +// +// The decay reference time is the database clock (age is computed in SQL via +// now()), so no Go-side timestamp is threaded here; a phase-3 context model +// that needs a pinned reference time would add one. +func BuildTasteProfile( + ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, + userID pgtype.UUID, cfg Config, +) error { + q := dbq.New(pool) + + artistW, tagW, err := accumulate(ctx, q, userID, cfg) + if err != nil { + return err + } + applyFloor(artistW, cfg.ArtistFloor) + applyFloor(tagW, cfg.TagFloor) + + artists := rankWeighted(artistW, cfg.WeightEpsilon, cfg.MaxArtists, cfg.MaxNegArtists, uuidLess) + tags := rankWeighted(tagW, cfg.WeightEpsilon, cfg.MaxTags, cfg.MaxNegTags, + func(a, b string) bool { return a < b }) + + if err := persist(ctx, pool, userID, artists, tags); err != nil { + return err + } + logger.Debug("taste: profile rebuilt", + "user_id", uuidString(userID), "artists", len(artists), "tags", len(tags)) + return nil +} + +// accumulate sums decayed play engagement and like bonuses into artist/tag +// weight maps. +func accumulate( + ctx context.Context, q *dbq.Queries, userID pgtype.UUID, cfg Config, +) (map[pgtype.UUID]float64, map[string]float64, error) { + plays, err := q.ListPlayEngagementInputsForUser(ctx, dbq.ListPlayEngagementInputsForUserParams{ + UserID: userID, + Column2: cfg.WindowDays, + }) + if err != nil { + return nil, nil, fmt.Errorf("taste: load play engagement: %w", err) + } + artistW := make(map[pgtype.UUID]float64) + tagW := make(map[string]float64) + for _, p := range plays { + e := Engagement(p.Completion, cfg.Engagement) * decay(p.AgeDays, cfg.HalfLifeDays) + artistW[p.ArtistID] += e + for _, tag := range splitGenres(p.Genre) { + tagW[tag] += e + } + } + + likedTracks, err := q.ListLikedTrackTasteInputsForUser(ctx, userID) + if err != nil { + return nil, nil, fmt.Errorf("taste: load liked tracks: %w", err) + } + for _, lt := range likedTracks { + artistW[lt.ArtistID] += cfg.TrackLikeBonus + for _, tag := range splitGenres(lt.Genre) { + tagW[tag] += cfg.TagLikeBonus + } + } + + likedArtists, err := q.ListLikedArtistIDsForUser(ctx, userID) + if err != nil { + return nil, nil, fmt.Errorf("taste: load liked artists: %w", err) + } + for _, aid := range likedArtists { + artistW[aid] += cfg.ArtistLikeBonus + } + return artistW, tagW, nil +} + +// persist atomic-replaces the user's profile rows inside one transaction. +func persist( + ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID, + artists []weighted[pgtype.UUID], tags []weighted[string], +) error { + tx, err := pool.Begin(ctx) + if err != nil { + return fmt.Errorf("taste: begin tx: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + qtx := dbq.New(tx) + + if err := qtx.DeleteTasteProfileArtistsForUser(ctx, userID); err != nil { + return fmt.Errorf("taste: delete artists: %w", err) + } + for _, a := range artists { + if err := qtx.InsertTasteProfileArtist(ctx, dbq.InsertTasteProfileArtistParams{ + UserID: userID, ArtistID: a.Key, Weight: a.Weight, + }); err != nil { + return fmt.Errorf("taste: insert artist: %w", err) + } + } + if err := qtx.DeleteTasteProfileTagsForUser(ctx, userID); err != nil { + return fmt.Errorf("taste: delete tags: %w", err) + } + for _, t := range tags { + if err := qtx.InsertTasteProfileTag(ctx, dbq.InsertTasteProfileTagParams{ + UserID: userID, Tag: t.Key, Weight: t.Weight, + }); err != nil { + return fmt.Errorf("taste: insert tag: %w", err) + } + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("taste: commit: %w", err) + } + return nil +} + +// weighted is a key with its signed taste weight. +type weighted[K comparable] struct { + Key K + Weight float64 +} + +// rankWeighted drops near-zero entries, sorts by weight DESC (ties broken by +// keyLess for determinism), and caps to the top topN plus the most-negative +// botN so both ends of the signal survive without unbounded table growth. +func rankWeighted[K comparable]( + m map[K]float64, eps float64, topN, botN int, keyLess func(a, b K) bool, +) []weighted[K] { + all := make([]weighted[K], 0, len(m)) + for k, w := range m { + if w >= eps || w <= -eps { + all = append(all, weighted[K]{Key: k, Weight: w}) + } + } + sort.Slice(all, func(i, j int) bool { + if all[i].Weight != all[j].Weight { + return all[i].Weight > all[j].Weight + } + return keyLess(all[i].Key, all[j].Key) + }) + if topN+botN >= len(all) { + return all + } + out := make([]weighted[K], 0, topN+botN) + out = append(out, all[:topN]...) + out = append(out, all[len(all)-botN:]...) + return out +} + +// applyFloor clamps every weight in m up to floor (no entity goes more +// negative than the floor). +func applyFloor[K comparable](m map[K]float64, floor float64) { + for k, w := range m { + if w < floor { + m[k] = floor + } + } +} + +// uuidLess orders two UUIDs by raw bytes (deterministic tie-break). +func uuidLess(a, b pgtype.UUID) bool { + return bytes.Compare(a.Bytes[:], b.Bytes[:]) < 0 +} + +// uuidString renders a pgtype.UUID canonically for log lines. +func uuidString(u pgtype.UUID) string { + if !u.Valid { + return "" + } + return fmt.Sprintf("%x-%x-%x-%x-%x", + u.Bytes[0:4], u.Bytes[4:6], u.Bytes[6:8], u.Bytes[8:10], u.Bytes[10:16]) +} + +// splitGenres splits a track's denormalized genre string on the common +// multi-genre delimiters, trimming whitespace and dropping empties. Mirrors +// the recommendation package's splitter (kept local to avoid a dependency). +func splitGenres(genre *string) []string { + if genre == nil || *genre == "" { + return nil + } + parts := strings.FieldsFunc(*genre, func(r rune) bool { + return r == ';' || r == ',' + }) + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} diff --git a/internal/taste/profile_db_test.go b/internal/taste/profile_db_test.go new file mode 100644 index 00000000..8f301154 --- /dev/null +++ b/internal/taste/profile_db_test.go @@ -0,0 +1,249 @@ +package taste + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" +) + +var seedCounter uint64 + +func discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } + +func newPool(t *testing.T) *pgxpool.Pool { + t.Helper() + if testing.Short() { + t.Skip("skipping integration test in -short mode") + } + dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + if err := db.Migrate(dsn, discardLogger()); err != nil { + t.Fatalf("migrate: %v", err) + } + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + t.Cleanup(pool.Close) + dbtest.ResetDB(t, pool) + return pool +} + +func seedUser(t *testing.T, pool *pgxpool.Pool, name string) dbq.User { + t.Helper() + u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{ + Username: dbtest.TestUserPrefix + name, PasswordHash: "x", + ApiToken: name + "-token", IsAdmin: false, + }) + if err != nil { + t.Fatalf("seed user: %v", err) + } + return u +} + +func seedArtist(t *testing.T, pool *pgxpool.Pool, name string) dbq.Artist { + t.Helper() + a, err := dbq.New(pool).UpsertArtist(context.Background(), dbq.UpsertArtistParams{ + Name: name, SortName: name, + }) + if err != nil { + t.Fatalf("seed artist: %v", err) + } + return a +} + +// seedTrack creates an album + track under the given artist with a genre. +func seedTrack(t *testing.T, pool *pgxpool.Pool, artistID pgtype.UUID, genre string) dbq.Track { + t.Helper() + q := dbq.New(pool) + n := atomic.AddUint64(&seedCounter, 1) + al, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: fmt.Sprintf("Album %d", n), SortTitle: fmt.Sprintf("Album %d", n), ArtistID: artistID, + }) + if err != nil { + t.Fatalf("seed album: %v", err) + } + g := genre + tk, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: fmt.Sprintf("Track %d", n), AlbumID: al.ID, ArtistID: artistID, + DurationMs: 200000, FilePath: filepath.Join(t.TempDir(), fmt.Sprintf("t%d.mp3", n)), + FileSize: 100, FileFormat: "mp3", Genre: &g, + }) + if err != nil { + t.Fatalf("seed track: %v", err) + } + return tk +} + +// seedPlay inserts a play with an explicit completion_ratio at startedAt. +func seedPlay(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time, completion float64) { + t.Helper() + _, err := pool.Exec(context.Background(), ` + WITH s AS ( + INSERT INTO play_sessions (user_id, started_at, last_event_at) + VALUES ($1, $3, $3) RETURNING id + ) + INSERT INTO play_events (user_id, track_id, session_id, started_at, completion_ratio, was_skipped) + SELECT $1, $2, s.id, $3, $4, false FROM s + `, userID, trackID, startedAt, completion) + if err != nil { + t.Fatalf("seed play: %v", err) + } +} + +func artistWeight(t *testing.T, pool *pgxpool.Pool, userID, artistID pgtype.UUID) (float64, bool) { + t.Helper() + var w float64 + err := pool.QueryRow(context.Background(), + `SELECT weight FROM taste_profile_artists WHERE user_id = $1 AND artist_id = $2`, + userID, artistID).Scan(&w) + if errors.Is(err, pgx.ErrNoRows) { + return 0, false + } + if err != nil { + t.Fatalf("artist weight: %v", err) + } + return w, true +} + +func tagWeight(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID, tag string) (float64, bool) { + t.Helper() + var w float64 + err := pool.QueryRow(context.Background(), + `SELECT weight FROM taste_profile_tags WHERE user_id = $1 AND tag = $2`, + userID, tag).Scan(&w) + if errors.Is(err, pgx.ErrNoRows) { + return 0, false + } + if err != nil { + t.Fatalf("tag weight: %v", err) + } + return w, true +} + +// TestBuildTasteProfile_PositiveVsNegative: a completed-track artist earns a +// positive weight; an early-skipped artist earns a negative one. Genre tags +// follow the same sign. +func TestBuildTasteProfile_PositiveVsNegative(t *testing.T) { + pool := newPool(t) + now := time.Now().UTC() + u := seedUser(t, pool, "tp1") + + loved := seedArtist(t, pool, "Loved") + lovedTrack := seedTrack(t, pool, loved.ID, "Rock") + skipped := seedArtist(t, pool, "Skipped") + skippedTrack := seedTrack(t, pool, skipped.ID, "Noise") + for i := 0; i < 3; i++ { + seedPlay(t, pool, u.ID, lovedTrack.ID, now.Add(-time.Duration(i+1)*time.Hour), 1.0) + seedPlay(t, pool, u.ID, skippedTrack.ID, now.Add(-time.Duration(i+1)*time.Hour), 0.02) + } + + if err := BuildTasteProfile(context.Background(), pool, discardLogger(), u.ID, DefaultConfig()); err != nil { + t.Fatalf("build: %v", err) + } + + lw, ok := artistWeight(t, pool, u.ID, loved.ID) + if !ok || lw <= 0 { + t.Errorf("loved artist weight = %.3f (present=%v), want > 0", lw, ok) + } + sw, ok := artistWeight(t, pool, u.ID, skipped.ID) + if !ok || sw >= 0 { + t.Errorf("skipped artist weight = %.3f (present=%v), want < 0", sw, ok) + } + if rw, ok := tagWeight(t, pool, u.ID, "Rock"); !ok || rw <= 0 { + t.Errorf("Rock tag weight = %.3f (present=%v), want > 0", rw, ok) + } +} + +// TestBuildTasteProfile_AggregationProtectsArtist: one early-skip among many +// completed plays must NOT make a loved artist net-negative (the user's +// concern that a track-dislike shouldn't tank the artist). +func TestBuildTasteProfile_AggregationProtectsArtist(t *testing.T) { + pool := newPool(t) + now := time.Now().UTC() + u := seedUser(t, pool, "tp2") + fav := seedArtist(t, pool, "Fav") + + for i := 0; i < 4; i++ { + tk := seedTrack(t, pool, fav.ID, "Indie") + seedPlay(t, pool, u.ID, tk.ID, now.Add(-time.Duration(i+1)*time.Hour), 1.0) + seedPlay(t, pool, u.ID, tk.ID, now.Add(-time.Duration(i+5)*time.Hour), 0.95) + } + // One early-skipped track by the same artist. + skipTrack := seedTrack(t, pool, fav.ID, "Indie") + seedPlay(t, pool, u.ID, skipTrack.ID, now.Add(-2*time.Hour), 0.02) + + if err := BuildTasteProfile(context.Background(), pool, discardLogger(), u.ID, DefaultConfig()); err != nil { + t.Fatalf("build: %v", err) + } + w, ok := artistWeight(t, pool, u.ID, fav.ID) + if !ok || w <= 0 { + t.Errorf("fav artist weight = %.3f (present=%v), want > 0 despite one skip", w, ok) + } +} + +// TestBuildTasteProfile_ArtistLikeBonus: an explicitly-liked artist with no +// plays still earns a positive weight from the like bonus. +func TestBuildTasteProfile_ArtistLikeBonus(t *testing.T) { + pool := newPool(t) + u := seedUser(t, pool, "tp3") + liked := seedArtist(t, pool, "LikedOnly") + if _, err := pool.Exec(context.Background(), + `INSERT INTO general_likes_artists (user_id, artist_id) VALUES ($1, $2)`, + u.ID, liked.ID); err != nil { + t.Fatalf("seed artist like: %v", err) + } + + if err := BuildTasteProfile(context.Background(), pool, discardLogger(), u.ID, DefaultConfig()); err != nil { + t.Fatalf("build: %v", err) + } + w, ok := artistWeight(t, pool, u.ID, liked.ID) + if !ok || w <= 0 { + t.Errorf("liked-only artist weight = %.3f (present=%v), want > 0 from like bonus", w, ok) + } +} + +// TestBuildTasteProfile_AtomicReplace: a second build replaces, not appends. +func TestBuildTasteProfile_AtomicReplace(t *testing.T) { + pool := newPool(t) + ctx := context.Background() + now := time.Now().UTC() + u := seedUser(t, pool, "tp4") + a := seedArtist(t, pool, "A") + tk := seedTrack(t, pool, a.ID, "Rock") + seedPlay(t, pool, u.ID, tk.ID, now.Add(-time.Hour), 1.0) + + count := func() int { + var n int + if err := pool.QueryRow(ctx, + `SELECT count(*) FROM taste_profile_artists WHERE user_id = $1`, u.ID).Scan(&n); err != nil { + t.Fatalf("count: %v", err) + } + return n + } + for i := 0; i < 2; i++ { + if err := BuildTasteProfile(ctx, pool, discardLogger(), u.ID, DefaultConfig()); err != nil { + t.Fatalf("build %d: %v", i, err) + } + } + if got := count(); got != 1 { + t.Errorf("artist rows after two builds = %d, want 1 (atomic replace)", got) + } +} diff --git a/internal/taste/profile_test.go b/internal/taste/profile_test.go new file mode 100644 index 00000000..f3a9e472 --- /dev/null +++ b/internal/taste/profile_test.go @@ -0,0 +1,94 @@ +package taste + +import ( + "testing" + + "github.com/jackc/pgx/v5/pgtype" +) + +func strLess(a, b string) bool { return a < b } + +func TestRankWeighted_DropsEpsilonAndSortsDesc(t *testing.T) { + m := map[string]float64{"a": 3.0, "b": 1.0, "tiny": 0.01, "d": -2.0} + got := rankWeighted(m, 0.05, 10, 10, strLess) + if len(got) != 3 { + t.Fatalf("len = %d, want 3 (tiny dropped by epsilon)", len(got)) + } + wantOrder := []string{"a", "b", "d"} // 3, 1, -2 descending + for i, w := range wantOrder { + if got[i].Key != w { + t.Errorf("pos %d = %q, want %q", i, got[i].Key, w) + } + } +} + +func TestRankWeighted_CapsTopAndBottom(t *testing.T) { + m := map[string]float64{"e5": 5, "e4": 4, "e3": 3, "e2": 2, "e1": 1} + got := rankWeighted(m, 0.0, 1, 1, strLess) // top 1 + bottom 1 + if len(got) != 2 { + t.Fatalf("len = %d, want 2", len(got)) + } + if got[0].Key != "e5" || got[1].Key != "e1" { + t.Errorf("got [%s,%s], want [e5,e1]", got[0].Key, got[1].Key) + } +} + +func TestRankWeighted_DeterministicTieBreak(t *testing.T) { + m := map[string]float64{"z": 1.0, "a": 1.0, "m": 1.0} + got := rankWeighted(m, 0.0, 10, 10, strLess) + // Equal weights → ordered by keyLess (string asc): a, m, z. + want := []string{"a", "m", "z"} + for i, w := range want { + if got[i].Key != w { + t.Errorf("tie-break pos %d = %q, want %q", i, got[i].Key, w) + } + } +} + +func TestApplyFloor_ClampsNegatives(t *testing.T) { + m := map[string]float64{"a": -5, "b": 2, "c": -1} + applyFloor(m, -3) + if m["a"] != -3 { + t.Errorf("a = %.1f, want -3 (floored)", m["a"]) + } + if m["b"] != 2 { + t.Errorf("b = %.1f, want 2 (untouched)", m["b"]) + } + if m["c"] != -1 { + t.Errorf("c = %.1f, want -1 (above floor)", m["c"]) + } +} + +func TestSplitGenres(t *testing.T) { + mk := func(s string) *string { return &s } + cases := []struct { + in *string + want []string + }{ + {nil, nil}, + {mk(""), nil}, + {mk("Rock"), []string{"Rock"}}, + {mk(" Rock "), []string{"Rock"}}, + {mk("Rock; Pop, Jazz"), []string{"Rock", "Pop", "Jazz"}}, + } + for _, c := range cases { + got := splitGenres(c.in) + if len(got) != len(c.want) { + t.Errorf("splitGenres(%v) len = %d, want %d", c.in, len(got), len(c.want)) + continue + } + for i := range got { + if got[i] != c.want[i] { + t.Errorf("splitGenres(%v)[%d] = %q, want %q", c.in, i, got[i], c.want[i]) + } + } + } +} + +func TestUuidLess_Orders(t *testing.T) { + a := pgtype.UUID{Bytes: [16]byte{1}, Valid: true} + b := pgtype.UUID{Bytes: [16]byte{2}, Valid: true} + if !uuidLess(a, b) || uuidLess(b, a) { + t.Error("uuidLess byte ordering wrong") + } +}