feat(taste): phase 1 — persistent per-user taste profile from graded engagement (#796)
Build a persistent, decaying model of each user's taste, recomputed daily, that later phases consume across every recommendation surface. Phase 1 only BUILDS the object — no behaviour change to what's surfaced yet. Core mechanic — graded engagement (replaces binary was_skipped for learning; was_skipped stays for History): a play's completion ratio maps to a signal in [-1,+1] via two linear ramps (instant-skip → -1, ~0.30 neutral, ≥0.90 → +1). Time-decayed (half-life ~75d) so recent behaviour dominates and the profile tracks drift. Per operator constraints: - No explicit dislike button — negatives come only from passive behaviour (early skips). Nothing recorded to regret or opt out of. - Negatives are track-scoped; artist/tag weight is the decayed SUM of their tracks' engagement, so one skip nets out against many good plays (a DB test asserts a liked artist stays positive despite an early-skipped track). A floor clamp bounds how negative any single entity can get. - migration 0035: taste_profile_artists / taste_profile_tags (signed weight, indexed by (user, weight DESC)). - internal/taste: engagement.go (pure curve + decay) + profile.go (accumulate plays + like bonuses, floor damping, size caps, atomic-replace). - scheduler: rebuildUserDaily recomputes the profile before the playlist build (so phase 2 can read it), best-effort — a taste failure never blocks playlist building. Wired into the daily job + startup catch-up only (not manual/lazy rebuilds). - tests: pure (engagement curve, decay, ranking, floor, genre split) + DB-backed (positive/negative weights, aggregation-protects-artist, like bonus, atomic replace). All green vs real Postgres. Config knobs live in taste.DefaultConfig() for now; wiring them into the server RecommendationConfig is a later follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -497,6 +497,20 @@ type SystemPlaylistRun struct {
|
|||||||
LastError *string
|
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 {
|
type Track struct {
|
||||||
ID pgtype.UUID
|
ID pgtype.UUID
|
||||||
Title string
|
Title string
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -28,6 +28,7 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"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
|
// 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(
|
job, err := s.gocron.NewJob(
|
||||||
gocron.CronJob(cronExpr, false /* withSeconds */),
|
gocron.CronJob(cronExpr, false /* withSeconds */),
|
||||||
gocron.NewTask(func() {
|
gocron.NewTask(func() {
|
||||||
if err := BuildSystemPlaylists(ctx, s.pool, s.logger, userID, time.Now(), s.dataDir); err != nil {
|
s.rebuildUserDaily(ctx, userID, time.Now())
|
||||||
s.logger.Warn("scheduler: build failed",
|
|
||||||
"user_id", uuidStringPL(userID), "err", err)
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -220,10 +218,22 @@ func (s *Scheduler) reconcile(ctx context.Context) {
|
|||||||
// whose scheduled fire was missed during downtime get caught up.
|
// whose scheduled fire was missed during downtime get caught up.
|
||||||
func (s *Scheduler) runStartupCatchUp(ctx context.Context, users []dbq.ListActiveUsersWithTimezonesRow) {
|
func (s *Scheduler) runStartupCatchUp(ctx context.Context, users []dbq.ListActiveUsersWithTimezonesRow) {
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
if err := BuildSystemPlaylists(ctx, s.pool, s.logger, u.ID, time.Now(), s.dataDir); err != nil {
|
s.rebuildUserDaily(ctx, u.ID, time.Now())
|
||||||
s.logger.Warn("scheduler: startup catch-up build failed",
|
}
|
||||||
"user_id", uuidStringPL(u.ID), "err", err)
|
}
|
||||||
}
|
|
||||||
|
// 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, now, 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
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_DegenerateParamsNoPanic(t *testing.T) {
|
||||||
|
// Non-increasing thresholds must collapse ramps, not divide by zero.
|
||||||
|
p := EngagementParams{HardSkip: 0.5, NeutralCompletion: 0.5, FullCompletion: 0.5}
|
||||||
|
for _, c := range []float64{0, 0.25, 0.5, 0.75, 1} {
|
||||||
|
_ = Engagement(c, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
// 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"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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).
|
||||||
|
func BuildTasteProfile(
|
||||||
|
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger,
|
||||||
|
userID pgtype.UUID, now time.Time, 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
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, now, 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, now, 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)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
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, now, 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, now, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user