7d18a3c808
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>
322 lines
11 KiB
Go
322 lines
11 KiB
Go
// 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
|
||
}
|