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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user