diff --git a/internal/db/dbq/taste_profile.sql.go b/internal/db/dbq/taste_profile.sql.go index db865aab..d2f1fba9 100644 --- a/internal/db/dbq/taste_profile.sql.go +++ b/internal/db/dbq/taste_profile.sql.go @@ -87,19 +87,21 @@ func (q *Queries) ListLikedArtistIDsForUser(ctx context.Context, userID pgtype.U } const listLikedTrackTasteInputsForUser = `-- name: ListLikedTrackTasteInputsForUser :many -SELECT t.artist_id, t.genre +SELECT t.id AS track_id, 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 { + TrackID pgtype.UUID 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. +// (track_id, artist_id, genre) for each track the user has explicitly +// liked. Feeds the track-like bonus into the liked track's artist and +// tags; track_id keys the enriched track_tags lookup (#1490). func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID pgtype.UUID) ([]ListLikedTrackTasteInputsForUserRow, error) { rows, err := q.db.Query(ctx, listLikedTrackTasteInputsForUser, userID) if err != nil { @@ -109,7 +111,7 @@ func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID p var items []ListLikedTrackTasteInputsForUserRow for rows.Next() { var i ListLikedTrackTasteInputsForUserRow - if err := rows.Scan(&i.ArtistID, &i.Genre); err != nil { + if err := rows.Scan(&i.TrackID, &i.ArtistID, &i.Genre); err != nil { return nil, err } items = append(items, i) @@ -123,6 +125,7 @@ func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID p const listPlayEngagementInputsForUser = `-- name: ListPlayEngagementInputsForUser :many SELECT + t.id AS track_id, t.artist_id, t.genre, LEAST(GREATEST( @@ -146,6 +149,7 @@ type ListPlayEngagementInputsForUserParams struct { } type ListPlayEngagementInputsForUserRow struct { + TrackID pgtype.UUID ArtistID pgtype.UUID Genre *string Completion float64 @@ -169,6 +173,7 @@ func (q *Queries) ListPlayEngagementInputsForUser(ctx context.Context, arg ListP for rows.Next() { var i ListPlayEngagementInputsForUserRow if err := rows.Scan( + &i.TrackID, &i.ArtistID, &i.Genre, &i.Completion, diff --git a/internal/db/queries/taste_profile.sql b/internal/db/queries/taste_profile.sql index 01387670..53ea5bc7 100644 --- a/internal/db/queries/taste_profile.sql +++ b/internal/db/queries/taste_profile.sql @@ -8,6 +8,7 @@ -- 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.id AS track_id, t.artist_id, t.genre, LEAST(GREATEST( @@ -25,9 +26,10 @@ WHERE pe.user_id = $1 ); -- 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 +-- (track_id, artist_id, genre) for each track the user has explicitly +-- liked. Feeds the track-like bonus into the liked track's artist and +-- tags; track_id keys the enriched track_tags lookup (#1490). +SELECT t.id AS track_id, t.artist_id, t.genre FROM general_likes gl JOIN tracks t ON t.id = gl.track_id WHERE gl.user_id = $1; diff --git a/internal/taste/profile.go b/internal/taste/profile.go index c9696bdf..e53bdbbc 100644 --- a/internal/taste/profile.go +++ b/internal/taste/profile.go @@ -36,6 +36,16 @@ type Config struct { TrackLikeBonus float64 TagLikeBonus float64 + // EnrichedTagScale weights folksonomy tags (from the track_tags cache, + // #1490) relative to a track's raw ID3 genre when both fold into the tag + // facet. Each enriched tag contributes base × tag.weight × this scale, + // where base is the play's decayed engagement (or the tag-like bonus for + // a liked track) and tag.weight is the normalized folksonomy strength in + // [0,1]. Below 1 so the richer-but-noisier enriched vocabulary augments + // the ID3 genre signal without swamping it. 0 disables the enriched + // contribution entirely (falls back to genre-only). + EnrichedTagScale 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 @@ -58,19 +68,20 @@ type Config struct { // 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, + Engagement: DefaultEngagementParams(), + HalfLifeDays: 75, + WindowDays: 270, + ArtistLikeBonus: 3.0, + TrackLikeBonus: 1.0, + TagLikeBonus: 0.5, + EnrichedTagScale: 0.5, + ArtistFloor: -3.0, + TagFloor: -3.0, + WeightEpsilon: 0.05, + MaxArtists: 500, + MaxNegArtists: 150, + MaxTags: 200, + MaxNegTags: 60, } } @@ -121,6 +132,19 @@ func accumulate( if err != nil { return nil, nil, fmt.Errorf("taste: load play engagement: %w", err) } + // Enriched folksonomy tags keyed by track (#1490) — folded into the tag + // facet alongside raw ID3 genre so a coarse "Rock" gains the cached + // "post-punk / shoegaze / melancholic" vocabulary. Window matches the + // play window; a track with no cached tags simply contributes genre only. + playedTagRows, err := q.ListPlayedTrackTagsForUser(ctx, dbq.ListPlayedTrackTagsForUserParams{ + UserID: userID, + Column2: int32(cfg.WindowDays), + }) + if err != nil { + return nil, nil, fmt.Errorf("taste: load played track tags: %w", err) + } + playedTags := groupTagsByTrack(playedTagRows) + artistW := make(map[pgtype.UUID]float64) tagW := make(map[string]float64) for _, p := range plays { @@ -129,17 +153,24 @@ func accumulate( for _, tag := range splitGenres(p.Genre) { tagW[tag] += e } + foldEnrichedTags(tagW, playedTags[p.TrackID], e, cfg.EnrichedTagScale) } likedTracks, err := q.ListLikedTrackTasteInputsForUser(ctx, userID) if err != nil { return nil, nil, fmt.Errorf("taste: load liked tracks: %w", err) } + likedTagRows, err := q.ListLikedTrackTagsForUser(ctx, userID) + if err != nil { + return nil, nil, fmt.Errorf("taste: load liked track tags: %w", err) + } + likedTags := groupTagsByTrack(likedTagRows) for _, lt := range likedTracks { artistW[lt.ArtistID] += cfg.TrackLikeBonus for _, tag := range splitGenres(lt.Genre) { tagW[tag] += cfg.TagLikeBonus } + foldEnrichedTags(tagW, likedTags[lt.TrackID], cfg.TagLikeBonus, cfg.EnrichedTagScale) } likedArtists, err := q.ListLikedArtistIDsForUser(ctx, userID) @@ -152,6 +183,29 @@ func accumulate( return artistW, tagW, nil } +// groupTagsByTrack buckets flat (track, tag, weight) rows by track id so +// each play/like can fold in its track's enriched tags in one lookup. +func groupTagsByTrack(rows []dbq.TrackTag) map[pgtype.UUID][]dbq.TrackTag { + m := make(map[pgtype.UUID][]dbq.TrackTag) + for _, r := range rows { + m[r.TrackID] = append(m[r.TrackID], r) + } + return m +} + +// foldEnrichedTags adds each enriched tag to tagW weighted by +// base × tag.weight × scale — base is the play's decayed engagement or the +// tag-like bonus, tag.weight is the folksonomy strength in [0,1]. scale=0 +// disables the enriched contribution (genre-only fallback). +func foldEnrichedTags(tagW map[string]float64, tags []dbq.TrackTag, base, scale float64) { + if scale == 0 { + return + } + for _, t := range tags { + tagW[t.Tag] += base * t.Weight * scale + } +} + // persist atomic-replaces the user's profile rows inside one transaction. func persist( ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID, diff --git a/internal/taste/profile_test.go b/internal/taste/profile_test.go index f3a9e472..538601f7 100644 --- a/internal/taste/profile_test.go +++ b/internal/taste/profile_test.go @@ -4,10 +4,39 @@ import ( "testing" "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) func strLess(a, b string) bool { return a < b } +func TestFoldEnrichedTags(t *testing.T) { + tagW := map[string]float64{"rock": 1.0} // pre-existing ID3 genre weight + tags := []dbq.TrackTag{ + {Tag: "shoegaze", Weight: 1.0}, + {Tag: "melancholic", Weight: 0.5}, + {Tag: "rock", Weight: 0.4}, // overlaps genre → accumulates + } + // base=2 (engagement), scale=0.5 → contribution = 2 * weight * 0.5 = weight. + foldEnrichedTags(tagW, tags, 2.0, 0.5) + if got := tagW["shoegaze"]; got != 1.0 { + t.Errorf("shoegaze = %v, want 1.0", got) + } + if got := tagW["melancholic"]; got != 0.5 { + t.Errorf("melancholic = %v, want 0.5", got) + } + if got := tagW["rock"]; got != 1.4 { // 1.0 genre + 2*0.4*0.5 + t.Errorf("rock = %v, want 1.4", got) + } + + // scale=0 disables the enriched contribution entirely. + base := map[string]float64{"rock": 1.0} + foldEnrichedTags(base, tags, 2.0, 0) + if len(base) != 1 || base["rock"] != 1.0 { + t.Errorf("scale=0 should leave tagW untouched; got %v", base) + } +} + 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)