Merge PR #91: "You might like" rows + taste profile (learn + apply)
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 4m34s
release / Build signed APK (tag releases only) (push) Successful in 4m5s
release / Build + push container image (push) Successful in 15s

This commit was merged in pull request #91.
This commit is contained in:
2026-06-11 21:41:17 -04:00
28 changed files with 2340 additions and 18 deletions
+16
View File
@@ -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)
}
+1
View File
@@ -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)
+7
View File
@@ -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
+11 -7
View File
@@ -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,
+28
View File
@@ -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
}
+262
View File
@@ -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
}
+217
View File
@@ -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
}
@@ -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;
@@ -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);
@@ -0,0 +1,3 @@
-- Reverse 0035_taste_profile.up.sql.
DROP TABLE IF EXISTS taste_profile_tags;
DROP TABLE IF EXISTS taste_profile_artists;
@@ -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);
+66
View File
@@ -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;
+66
View File
@@ -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;
+18 -8
View File
@@ -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)
}
}
+41 -2
View File
@@ -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 "<nil>"
+227
View File
@@ -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
}
@@ -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)
}
}
+125
View File
@@ -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))
}
}
+12
View File
@@ -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),
},
})
}
+95 -1
View File
@@ -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.
+8
View File
@@ -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
}
+91
View File
@@ -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
}
+109
View File
@@ -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)
}
}
+81
View File
@@ -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
}
+75
View File
@@ -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)
}
}
+267
View File
@@ -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 "<nil>"
}
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
}
+249
View File
@@ -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)
}
}
+94
View File
@@ -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")
}
}