diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index 641f6b90..a30f8d86 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -497,6 +497,20 @@ type SystemPlaylistRun struct { LastError *string } +type TasteProfileArtist struct { + UserID pgtype.UUID + ArtistID pgtype.UUID + Weight float64 + UpdatedAt pgtype.Timestamptz +} + +type TasteProfileTag struct { + UserID pgtype.UUID + Tag string + Weight float64 + UpdatedAt pgtype.Timestamptz +} + type Track struct { ID pgtype.UUID Title string diff --git a/internal/db/dbq/taste_profile.sql.go b/internal/db/dbq/taste_profile.sql.go new file mode 100644 index 00000000..db865aab --- /dev/null +++ b/internal/db/dbq/taste_profile.sql.go @@ -0,0 +1,262 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: taste_profile.sql + +package dbq + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const deleteTasteProfileArtistsForUser = `-- name: DeleteTasteProfileArtistsForUser :exec +DELETE FROM taste_profile_artists WHERE user_id = $1 +` + +func (q *Queries) DeleteTasteProfileArtistsForUser(ctx context.Context, userID pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteTasteProfileArtistsForUser, userID) + return err +} + +const deleteTasteProfileTagsForUser = `-- name: DeleteTasteProfileTagsForUser :exec +DELETE FROM taste_profile_tags WHERE user_id = $1 +` + +func (q *Queries) DeleteTasteProfileTagsForUser(ctx context.Context, userID pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteTasteProfileTagsForUser, userID) + return err +} + +const insertTasteProfileArtist = `-- name: InsertTasteProfileArtist :exec +INSERT INTO taste_profile_artists (user_id, artist_id, weight) +VALUES ($1, $2, $3) +` + +type InsertTasteProfileArtistParams struct { + UserID pgtype.UUID + ArtistID pgtype.UUID + Weight float64 +} + +func (q *Queries) InsertTasteProfileArtist(ctx context.Context, arg InsertTasteProfileArtistParams) error { + _, err := q.db.Exec(ctx, insertTasteProfileArtist, arg.UserID, arg.ArtistID, arg.Weight) + return err +} + +const insertTasteProfileTag = `-- name: InsertTasteProfileTag :exec +INSERT INTO taste_profile_tags (user_id, tag, weight) +VALUES ($1, $2, $3) +` + +type InsertTasteProfileTagParams struct { + UserID pgtype.UUID + Tag string + Weight float64 +} + +func (q *Queries) InsertTasteProfileTag(ctx context.Context, arg InsertTasteProfileTagParams) error { + _, err := q.db.Exec(ctx, insertTasteProfileTag, arg.UserID, arg.Tag, arg.Weight) + return err +} + +const listLikedArtistIDsForUser = `-- name: ListLikedArtistIDsForUser :many +SELECT artist_id FROM general_likes_artists WHERE user_id = $1 +` + +// Artists the user has explicitly liked; feeds the artist-like bonus. +func (q *Queries) ListLikedArtistIDsForUser(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) { + rows, err := q.db.Query(ctx, listLikedArtistIDsForUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []pgtype.UUID + for rows.Next() { + var artist_id pgtype.UUID + if err := rows.Scan(&artist_id); err != nil { + return nil, err + } + items = append(items, artist_id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listLikedTrackTasteInputsForUser = `-- name: ListLikedTrackTasteInputsForUser :many +SELECT t.artist_id, t.genre +FROM general_likes gl +JOIN tracks t ON t.id = gl.track_id +WHERE gl.user_id = $1 +` + +type ListLikedTrackTasteInputsForUserRow struct { + ArtistID pgtype.UUID + Genre *string +} + +// (artist_id, genre) for each track the user has explicitly liked. Feeds the +// track-like bonus into the liked track's artist and tags. +func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID pgtype.UUID) ([]ListLikedTrackTasteInputsForUserRow, error) { + rows, err := q.db.Query(ctx, listLikedTrackTasteInputsForUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListLikedTrackTasteInputsForUserRow + for rows.Next() { + var i ListLikedTrackTasteInputsForUserRow + if err := rows.Scan(&i.ArtistID, &i.Genre); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listPlayEngagementInputsForUser = `-- name: ListPlayEngagementInputsForUser :many + +SELECT + t.artist_id, + t.genre, + LEAST(GREATEST( + COALESCE(pe.completion_ratio, + pe.duration_played_ms::float8 / NULLIF(t.duration_ms, 0), + 0), 0), 1)::float8 AS completion, + (EXTRACT(epoch FROM now() - pe.started_at) / 86400.0)::float8 AS age_days +FROM play_events pe +JOIN tracks t ON t.id = pe.track_id +WHERE pe.user_id = $1 + AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day') + AND NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $1 AND q.track_id = t.id + ) +` + +type ListPlayEngagementInputsForUserParams struct { + UserID pgtype.UUID + Column2 float64 +} + +type ListPlayEngagementInputsForUserRow struct { + ArtistID pgtype.UUID + Genre *string + Completion float64 + AgeDays float64 +} + +// Taste profile (#796 phase 1). The daily build computes signed artist/tag +// weights in Go (graded engagement + decay + like bonus) and atomic-replaces +// them here. Read queries serve "top taste" lookups (used in phase 2). +// One row per play in the decay-relevant window. completion is the play's +// completion ratio (precomputed column when present, else duration_played / +// track duration, clamped to [0,1]); age_days drives the time-decay. Genre +// is split into tags in Go. Quarantined tracks are excluded. +func (q *Queries) ListPlayEngagementInputsForUser(ctx context.Context, arg ListPlayEngagementInputsForUserParams) ([]ListPlayEngagementInputsForUserRow, error) { + rows, err := q.db.Query(ctx, listPlayEngagementInputsForUser, arg.UserID, arg.Column2) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListPlayEngagementInputsForUserRow + for rows.Next() { + var i ListPlayEngagementInputsForUserRow + if err := rows.Scan( + &i.ArtistID, + &i.Genre, + &i.Completion, + &i.AgeDays, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listTasteProfileArtistsForUser = `-- name: ListTasteProfileArtistsForUser :many +SELECT artist_id, weight +FROM taste_profile_artists +WHERE user_id = $1 +ORDER BY weight DESC +LIMIT $2 +` + +type ListTasteProfileArtistsForUserParams struct { + UserID pgtype.UUID + Limit int32 +} + +type ListTasteProfileArtistsForUserRow struct { + ArtistID pgtype.UUID + Weight float64 +} + +// Top-weighted taste artists (phase-2 consumption + tests). +func (q *Queries) ListTasteProfileArtistsForUser(ctx context.Context, arg ListTasteProfileArtistsForUserParams) ([]ListTasteProfileArtistsForUserRow, error) { + rows, err := q.db.Query(ctx, listTasteProfileArtistsForUser, arg.UserID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListTasteProfileArtistsForUserRow + for rows.Next() { + var i ListTasteProfileArtistsForUserRow + if err := rows.Scan(&i.ArtistID, &i.Weight); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listTasteProfileTagsForUser = `-- name: ListTasteProfileTagsForUser :many +SELECT tag, weight +FROM taste_profile_tags +WHERE user_id = $1 +ORDER BY weight DESC +LIMIT $2 +` + +type ListTasteProfileTagsForUserParams struct { + UserID pgtype.UUID + Limit int32 +} + +type ListTasteProfileTagsForUserRow struct { + Tag string + Weight float64 +} + +func (q *Queries) ListTasteProfileTagsForUser(ctx context.Context, arg ListTasteProfileTagsForUserParams) ([]ListTasteProfileTagsForUserRow, error) { + rows, err := q.db.Query(ctx, listTasteProfileTagsForUser, arg.UserID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListTasteProfileTagsForUserRow + for rows.Next() { + var i ListTasteProfileTagsForUserRow + if err := rows.Scan(&i.Tag, &i.Weight); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/db/migrations/0035_taste_profile.down.sql b/internal/db/migrations/0035_taste_profile.down.sql new file mode 100644 index 00000000..4af083e7 --- /dev/null +++ b/internal/db/migrations/0035_taste_profile.down.sql @@ -0,0 +1,3 @@ +-- Reverse 0035_taste_profile.up.sql. +DROP TABLE IF EXISTS taste_profile_tags; +DROP TABLE IF EXISTS taste_profile_artists; diff --git a/internal/db/migrations/0035_taste_profile.up.sql b/internal/db/migrations/0035_taste_profile.up.sql new file mode 100644 index 00000000..595375a3 --- /dev/null +++ b/internal/db/migrations/0035_taste_profile.up.sql @@ -0,0 +1,33 @@ +-- 0035_taste_profile.up.sql — per-user taste profile (#796 phase 1). +-- +-- A persistent, decaying model of each user's taste, rebuilt daily from +-- graded play engagement (completion + skip position) plus explicit +-- likes. Two facet tables: weighted artists and weighted genre tags. +-- Weight is a signed float — positive = drawn to, negative = (passively) +-- avoided; magnitude reflects decayed engagement. Phase 1 only BUILDS +-- these; consumption (candidate sourcing + scoring) lands in phase 2. +-- +-- CASCADE on user/artist deletes keeps them clean. Indexed by +-- (user_id, weight DESC) to serve "top taste artists/tags" reads cheaply. + +CREATE TABLE taste_profile_artists ( + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + artist_id uuid NOT NULL REFERENCES artists(id) ON DELETE CASCADE, + weight double precision NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, artist_id) +); + +CREATE INDEX taste_profile_artists_user_weight_idx + ON taste_profile_artists (user_id, weight DESC); + +CREATE TABLE taste_profile_tags ( + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tag text NOT NULL, + weight double precision NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, tag) +); + +CREATE INDEX taste_profile_tags_user_weight_idx + ON taste_profile_tags (user_id, weight DESC); diff --git a/internal/db/queries/taste_profile.sql b/internal/db/queries/taste_profile.sql new file mode 100644 index 00000000..01387670 --- /dev/null +++ b/internal/db/queries/taste_profile.sql @@ -0,0 +1,66 @@ +-- Taste profile (#796 phase 1). The daily build computes signed artist/tag +-- weights in Go (graded engagement + decay + like bonus) and atomic-replaces +-- them here. Read queries serve "top taste" lookups (used in phase 2). + +-- name: ListPlayEngagementInputsForUser :many +-- One row per play in the decay-relevant window. completion is the play's +-- completion ratio (precomputed column when present, else duration_played / +-- track duration, clamped to [0,1]); age_days drives the time-decay. Genre +-- is split into tags in Go. Quarantined tracks are excluded. +SELECT + t.artist_id, + t.genre, + LEAST(GREATEST( + COALESCE(pe.completion_ratio, + pe.duration_played_ms::float8 / NULLIF(t.duration_ms, 0), + 0), 0), 1)::float8 AS completion, + (EXTRACT(epoch FROM now() - pe.started_at) / 86400.0)::float8 AS age_days +FROM play_events pe +JOIN tracks t ON t.id = pe.track_id +WHERE pe.user_id = $1 + AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day') + AND NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $1 AND q.track_id = t.id + ); + +-- name: ListLikedTrackTasteInputsForUser :many +-- (artist_id, genre) for each track the user has explicitly liked. Feeds the +-- track-like bonus into the liked track's artist and tags. +SELECT t.artist_id, t.genre +FROM general_likes gl +JOIN tracks t ON t.id = gl.track_id +WHERE gl.user_id = $1; + +-- name: ListLikedArtistIDsForUser :many +-- Artists the user has explicitly liked; feeds the artist-like bonus. +SELECT artist_id FROM general_likes_artists WHERE user_id = $1; + +-- name: DeleteTasteProfileArtistsForUser :exec +DELETE FROM taste_profile_artists WHERE user_id = $1; + +-- name: InsertTasteProfileArtist :exec +INSERT INTO taste_profile_artists (user_id, artist_id, weight) +VALUES ($1, $2, $3); + +-- name: DeleteTasteProfileTagsForUser :exec +DELETE FROM taste_profile_tags WHERE user_id = $1; + +-- name: InsertTasteProfileTag :exec +INSERT INTO taste_profile_tags (user_id, tag, weight) +VALUES ($1, $2, $3); + +-- name: ListTasteProfileArtistsForUser :many +-- Top-weighted taste artists (phase-2 consumption + tests). +SELECT artist_id, weight +FROM taste_profile_artists +WHERE user_id = $1 +ORDER BY weight DESC +LIMIT $2; + +-- name: ListTasteProfileTagsForUser :many +SELECT tag, weight +FROM taste_profile_tags +WHERE user_id = $1 +ORDER BY weight DESC +LIMIT $2; diff --git a/internal/playlists/scheduler.go b/internal/playlists/scheduler.go index 96376584..ae6c7901 100644 --- a/internal/playlists/scheduler.go +++ b/internal/playlists/scheduler.go @@ -28,6 +28,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/taste" ) // dailyRebuildHour is the local-time hour at which each user's system @@ -158,10 +159,7 @@ func (s *Scheduler) registerLocked(ctx context.Context, userID pgtype.UUID, tz s job, err := s.gocron.NewJob( gocron.CronJob(cronExpr, false /* withSeconds */), gocron.NewTask(func() { - if err := BuildSystemPlaylists(ctx, s.pool, s.logger, userID, time.Now(), s.dataDir); err != nil { - s.logger.Warn("scheduler: build failed", - "user_id", uuidStringPL(userID), "err", err) - } + s.rebuildUserDaily(ctx, userID, time.Now()) }), ) if err != nil { @@ -220,10 +218,22 @@ func (s *Scheduler) reconcile(ctx context.Context) { // whose scheduled fire was missed during downtime get caught up. func (s *Scheduler) runStartupCatchUp(ctx context.Context, users []dbq.ListActiveUsersWithTimezonesRow) { for _, u := range users { - if err := BuildSystemPlaylists(ctx, s.pool, s.logger, u.ID, time.Now(), s.dataDir); err != nil { - s.logger.Warn("scheduler: startup catch-up build failed", - "user_id", uuidStringPL(u.ID), "err", err) - } + s.rebuildUserDaily(ctx, u.ID, time.Now()) + } +} + +// rebuildUserDaily recomputes the user's taste profile, then rebuilds their +// system playlists + You-might-like rows. Taste runs first so the playlist +// build can read a fresh profile (phase-2 consumption); both steps are +// best-effort and a failure in one is logged without blocking the other. +func (s *Scheduler) rebuildUserDaily(ctx context.Context, userID pgtype.UUID, now time.Time) { + if err := taste.BuildTasteProfile(ctx, s.pool, s.logger, userID, 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) } } diff --git a/internal/taste/engagement.go b/internal/taste/engagement.go new file mode 100644 index 00000000..40bcab7d --- /dev/null +++ b/internal/taste/engagement.go @@ -0,0 +1,81 @@ +// Package taste builds a persistent, decaying per-user taste profile (#796). +// engagement.go is the pure core: it turns a single play's completion ratio +// into a graded taste signal in [-1, +1], replacing the binary was_skipped +// for learning purposes (was_skipped stays as-is for History display). +package taste + +import "math" + +// EngagementParams tune the completion→engagement curve. All in [0,1] and +// ordered HardSkip < NeutralCompletion < FullCompletion. +type EngagementParams struct { + // HardSkip: completion at/below which a play is a strong reject (-1). + HardSkip float64 + // NeutralCompletion: completion at which a play is neutral (0). Set low + // (~0.30) because people enjoy plenty of tracks they don't finish; only + // genuinely-early abandons read as negative. + NeutralCompletion float64 + // FullCompletion: completion at/above which a play is a full positive + // (+1). + FullCompletion float64 +} + +// DefaultEngagementParams returns the tuned starting curve. +func DefaultEngagementParams() EngagementParams { + return EngagementParams{HardSkip: 0.05, NeutralCompletion: 0.30, FullCompletion: 0.90} +} + +// Engagement maps a play's completion ratio to a taste signal in [-1, +1] +// via two linear ramps meeting at the neutral point: +// +// completion <= HardSkip → -1 +// HardSkip < c < Neutral → ramp -1 → 0 +// Neutral <= c < Full → ramp 0 → +1 +// completion >= Full → +1 +// +// Completion is clamped to [0,1]. Degenerate params (non-increasing +// thresholds) collapse the affected ramp to its endpoint rather than +// dividing by zero. +func Engagement(completion float64, p EngagementParams) float64 { + c := clamp01(completion) + if c <= p.HardSkip { + return -1 + } + if c < p.NeutralCompletion { + span := p.NeutralCompletion - p.HardSkip + if span <= 0 { + return 0 + } + return -1 + (c-p.HardSkip)/span + } + if c < p.FullCompletion { + span := p.FullCompletion - p.NeutralCompletion + if span <= 0 { + return 1 + } + return (c - p.NeutralCompletion) / span + } + return 1 +} + +// decay is the exponential time-decay applied to a play's engagement: +// 1.0 at age 0, 0.5 at age == halfLifeDays. Negative ages clamp to 0. +func decay(ageDays, halfLifeDays float64) float64 { + if ageDays < 0 { + ageDays = 0 + } + if halfLifeDays <= 0 { + return 1 + } + return math.Exp(-math.Ln2 * ageDays / halfLifeDays) +} + +func clamp01(x float64) float64 { + if x < 0 { + return 0 + } + if x > 1 { + return 1 + } + return x +} diff --git a/internal/taste/engagement_test.go b/internal/taste/engagement_test.go new file mode 100644 index 00000000..3c1fd1f8 --- /dev/null +++ b/internal/taste/engagement_test.go @@ -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) + } +} diff --git a/internal/taste/profile.go b/internal/taste/profile.go new file mode 100644 index 00000000..195e93fc --- /dev/null +++ b/internal/taste/profile.go @@ -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 "" + } + return fmt.Sprintf("%x-%x-%x-%x-%x", + u.Bytes[0:4], u.Bytes[4:6], u.Bytes[6:8], u.Bytes[8:10], u.Bytes[10:16]) +} + +// splitGenres splits a track's denormalized genre string on the common +// multi-genre delimiters, trimming whitespace and dropping empties. Mirrors +// the recommendation package's splitter (kept local to avoid a dependency). +func splitGenres(genre *string) []string { + if genre == nil || *genre == "" { + return nil + } + parts := strings.FieldsFunc(*genre, func(r rune) bool { + return r == ';' || r == ',' + }) + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} diff --git a/internal/taste/profile_db_test.go b/internal/taste/profile_db_test.go new file mode 100644 index 00000000..2d9968a8 --- /dev/null +++ b/internal/taste/profile_db_test.go @@ -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) + } +} diff --git a/internal/taste/profile_test.go b/internal/taste/profile_test.go new file mode 100644 index 00000000..f3a9e472 --- /dev/null +++ b/internal/taste/profile_test.go @@ -0,0 +1,94 @@ +package taste + +import ( + "testing" + + "github.com/jackc/pgx/v5/pgtype" +) + +func strLess(a, b string) bool { return a < b } + +func TestRankWeighted_DropsEpsilonAndSortsDesc(t *testing.T) { + m := map[string]float64{"a": 3.0, "b": 1.0, "tiny": 0.01, "d": -2.0} + got := rankWeighted(m, 0.05, 10, 10, strLess) + if len(got) != 3 { + t.Fatalf("len = %d, want 3 (tiny dropped by epsilon)", len(got)) + } + wantOrder := []string{"a", "b", "d"} // 3, 1, -2 descending + for i, w := range wantOrder { + if got[i].Key != w { + t.Errorf("pos %d = %q, want %q", i, got[i].Key, w) + } + } +} + +func TestRankWeighted_CapsTopAndBottom(t *testing.T) { + m := map[string]float64{"e5": 5, "e4": 4, "e3": 3, "e2": 2, "e1": 1} + got := rankWeighted(m, 0.0, 1, 1, strLess) // top 1 + bottom 1 + if len(got) != 2 { + t.Fatalf("len = %d, want 2", len(got)) + } + if got[0].Key != "e5" || got[1].Key != "e1" { + t.Errorf("got [%s,%s], want [e5,e1]", got[0].Key, got[1].Key) + } +} + +func TestRankWeighted_DeterministicTieBreak(t *testing.T) { + m := map[string]float64{"z": 1.0, "a": 1.0, "m": 1.0} + got := rankWeighted(m, 0.0, 10, 10, strLess) + // Equal weights → ordered by keyLess (string asc): a, m, z. + want := []string{"a", "m", "z"} + for i, w := range want { + if got[i].Key != w { + t.Errorf("tie-break pos %d = %q, want %q", i, got[i].Key, w) + } + } +} + +func TestApplyFloor_ClampsNegatives(t *testing.T) { + m := map[string]float64{"a": -5, "b": 2, "c": -1} + applyFloor(m, -3) + if m["a"] != -3 { + t.Errorf("a = %.1f, want -3 (floored)", m["a"]) + } + if m["b"] != 2 { + t.Errorf("b = %.1f, want 2 (untouched)", m["b"]) + } + if m["c"] != -1 { + t.Errorf("c = %.1f, want -1 (above floor)", m["c"]) + } +} + +func TestSplitGenres(t *testing.T) { + mk := func(s string) *string { return &s } + cases := []struct { + in *string + want []string + }{ + {nil, nil}, + {mk(""), nil}, + {mk("Rock"), []string{"Rock"}}, + {mk(" Rock "), []string{"Rock"}}, + {mk("Rock; Pop, Jazz"), []string{"Rock", "Pop", "Jazz"}}, + } + for _, c := range cases { + got := splitGenres(c.in) + if len(got) != len(c.want) { + t.Errorf("splitGenres(%v) len = %d, want %d", c.in, len(got), len(c.want)) + continue + } + for i := range got { + if got[i] != c.want[i] { + t.Errorf("splitGenres(%v)[%d] = %q, want %q", c.in, i, got[i], c.want[i]) + } + } + } +} + +func TestUuidLess_Orders(t *testing.T) { + a := pgtype.UUID{Bytes: [16]byte{1}, Valid: true} + b := pgtype.UUID{Bytes: [16]byte{2}, Valid: true} + if !uuidLess(a, b) || uuidLess(b, a) { + t.Error("uuidLess byte ordering wrong") + } +}