feat(taste): fold enriched folksonomy tags into the profile (#1490 Step 3)
The taste recompute's tag facet now unions the cached track_tags (MusicBrainz/Last.fm folksonomy tags) alongside raw ID3 genre, so a coarse "Rock" gains "post-punk / shoegaze / melancholic". - taste_profile.sql: ListPlayEngagementInputsForUser + ListLikedTrackTasteInputsForUser now return track_id to key the enriched-tag lookup. - accumulate(): for each play, fold its track's enriched tags weighted by engagement × tag.weight × EnrichedTagScale; for each liked track, by the tag-like bonus × tag.weight × scale. A track with no cached tags contributes genre only (graceful). - New Config.EnrichedTagScale (default 0.5) — enriched tags augment the ID3 signal without swamping it; 0 = genre-only. Flows through recsettings.TasteConfig() (starts from DefaultConfig). Promoting it into the admin tuning lab is a small follow-up. Unit-tested the pure foldEnrichedTags helper (overlap accumulation + scale=0 disable). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
+67
-13
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user