Files
minstrel/internal/taste/profile.go
T
bvandeusen 7d18a3c808
test-go / test (push) Successful in 30s
test-go / integration (push) Successful in 4m43s
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>
2026-07-13 22:09:31 -04:00

322 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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"
"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
// 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
// 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,
EnrichedTagScale: 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).
//
// The decay reference time is the database clock (age is computed in SQL via
// now()), so no Go-side timestamp is threaded here; a phase-3 context model
// that needs a pinned reference time would add one.
func BuildTasteProfile(
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger,
userID pgtype.UUID, 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)
}
// 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 {
e := Engagement(p.Completion, cfg.Engagement) * decay(p.AgeDays, cfg.HalfLifeDays)
artistW[p.ArtistID] += e
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)
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
}
// 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,
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
}