Files
minstrel/internal/taste/profile.go
T
bvandeusen f0c08e7326
test-go / test (push) Successful in 34s
test-web / test (push) Successful in 40s
test-go / integration (push) Successful in 4m44s
feat(taste): mood taste facet — #1534
Milestone #160 Opt 2b (mood half of the era+mood option). A fourth taste
facet alongside artists + genre tags + eras: signed weights over canonical
mood buckets (melancholic / energetic / chill / …) derived from a track's
enriched folksonomy tags (#1490).

- internal/mood: shared vocabulary — Of(tags) maps folksonomy tags to
  canonical mood buckets (synonyms collapse). Imported by both the taste
  builder and the scorer so a track's mood is derived identically.
- Migration 0047: taste_profile_moods table + taste_tuning.mood_scale
  (DEFAULT 0.5).
- Build side (internal/taste): Config.MoodScale ([0,1] damper, mirrors
  EraScale); accumulate folds each play/like's mood buckets at
  base*MoodScale; persist atomic-replaces the mood rows.
- Scorer (internal/recommendation): TasteProfile gains a mood term
  (own tanh scale + additive 0.12 share, so it never weakens the existing
  signal when a track has no mood tags). Match now takes the candidate's
  mood buckets; loaded per candidate (ListTrackTagsForTracks → mood.Of) in
  the primary similarity loader only — the near-whole-library fallback
  pool passes nil (mood → 0) to avoid a full-library tag scan.
- Tuning lab: mood_scale threaded through recsettings + admin API + web
  card ("Mood weight" row) + Go/web tests.

Coverage is partial (grows with tag enrichment; richer once Last.fm is
keyed), so mood is a supplement — neutral for tracks with no mood tags.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 10:32:41 -04:00

423 lines
15 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"
"git.fabledsword.com/bvandeusen/minstrel/internal/mood"
)
// 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
// EraScale weights the decade/era facet (#1530): each play adds
// base × this scale to its album's decade bucket ("1990s"), where base
// is the decayed engagement (or the tag-like bonus for a liked track).
// Era is a coarse signal, so it's kept ≤ 1 to imprint at most as strongly
// as the raw engagement; 0 disables the era facet entirely. Tunable in the
// lab (mirrors EnrichedTagScale, #1520).
EraScale float64
// MoodScale weights the mood facet (#1534): each play adds base × this
// scale to the canonical mood buckets (melancholic / chill / …) derived
// from its enriched folksonomy tags via internal/mood. Coverage is partial
// (depends on tag enrichment), so it's a supplement kept ≤ 1; 0 disables
// the mood facet entirely. Tunable in the lab (mirrors EraScale).
MoodScale 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
// and eras. Real libraries span ~a dozen decades, so the era caps rarely
// bind — they exist for symmetry with the other facets.
MaxArtists int
MaxNegArtists int
MaxTags int
MaxNegTags int
MaxEras int
MaxNegEras int
MaxMoods int
MaxNegMoods 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,
EraScale: 0.5,
MoodScale: 0.5,
ArtistFloor: -3.0,
TagFloor: -3.0,
WeightEpsilon: 0.05,
MaxArtists: 500,
MaxNegArtists: 150,
MaxTags: 200,
MaxNegTags: 60,
MaxEras: 30,
MaxNegEras: 15,
MaxMoods: 20,
MaxNegMoods: 10,
}
}
// 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, eraW, moodW, err := accumulate(ctx, q, userID, cfg)
if err != nil {
return err
}
applyFloor(artistW, cfg.ArtistFloor)
applyFloor(tagW, cfg.TagFloor)
// Era + mood share the tag floor (all text-keyed facets; a decade or mood
// that's been heavily skipped shouldn't dominate as an extreme negative).
applyFloor(eraW, cfg.TagFloor)
applyFloor(moodW, cfg.TagFloor)
strLess := func(a, b string) bool { return a < b }
artists := rankWeighted(artistW, cfg.WeightEpsilon, cfg.MaxArtists, cfg.MaxNegArtists, uuidLess)
tags := rankWeighted(tagW, cfg.WeightEpsilon, cfg.MaxTags, cfg.MaxNegTags, strLess)
eras := rankWeighted(eraW, cfg.WeightEpsilon, cfg.MaxEras, cfg.MaxNegEras, strLess)
moods := rankWeighted(moodW, cfg.WeightEpsilon, cfg.MaxMoods, cfg.MaxNegMoods, strLess)
if err := persist(ctx, pool, userID, artists, tags, eras, moods); err != nil {
return err
}
logger.Debug("taste: profile rebuilt", "user_id", uuidString(userID),
"artists", len(artists), "tags", len(tags), "eras", len(eras), "moods", len(moods))
return nil
}
// accumulate sums decayed play engagement and like bonuses into
// artist/tag/era/mood weight maps.
func accumulate(
ctx context.Context, q *dbq.Queries, userID pgtype.UUID, cfg Config,
) (map[pgtype.UUID]float64, map[string]float64, map[string]float64, map[string]float64, error) {
plays, err := q.ListPlayEngagementInputsForUser(ctx, dbq.ListPlayEngagementInputsForUserParams{
UserID: userID,
Column2: cfg.WindowDays,
})
if err != nil {
return nil, nil, 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, 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)
eraW := make(map[string]float64)
moodW := 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)
foldEra(eraW, p.ReleaseDate, e, cfg.EraScale)
foldMoods(moodW, playedTags[p.TrackID], e, cfg.MoodScale)
}
likedTracks, err := q.ListLikedTrackTasteInputsForUser(ctx, userID)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("taste: load liked tracks: %w", err)
}
likedTagRows, err := q.ListLikedTrackTagsForUser(ctx, userID)
if err != nil {
return nil, nil, 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)
foldEra(eraW, lt.ReleaseDate, cfg.TagLikeBonus, cfg.EraScale)
foldMoods(moodW, likedTags[lt.TrackID], cfg.TagLikeBonus, cfg.MoodScale)
}
likedArtists, err := q.ListLikedArtistIDsForUser(ctx, userID)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("taste: load liked artists: %w", err)
}
for _, aid := range likedArtists {
artistW[aid] += cfg.ArtistLikeBonus
}
return artistW, tagW, eraW, moodW, 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
}
}
// foldEra adds a track's decade bucket to eraW weighted by base × scale — base
// is the play's decayed engagement or the tag-like bonus, scale (EraScale)
// dials the facet's strength. scale=0 disables the era facet; undated tracks
// (decade "") contribute nothing so they don't pollute the profile.
func foldEra(eraW map[string]float64, d pgtype.Date, base, scale float64) {
if scale == 0 {
return
}
if decade := decadeOf(d); decade != "" {
eraW[decade] += base * scale
}
}
// foldMoods adds a track's canonical mood buckets (derived from its enriched
// folksonomy tags via internal/mood) to moodW weighted by base × scale. base is
// the play's decayed engagement or the tag-like bonus; scale (MoodScale) dials
// the facet's strength. scale=0 disables the mood facet; tracks with no
// mood-word tags contribute nothing.
func foldMoods(moodW map[string]float64, tags []dbq.TrackTag, base, scale float64) {
if scale == 0 {
return
}
names := make([]string, len(tags))
for i, t := range tags {
names[i] = t.Tag
}
for _, m := range mood.Of(names) {
moodW[m] += base * scale
}
}
// decadeOf maps an album release date to a decade bucket like "1990s", or ""
// for an absent/invalid date. Mirrored by the recommendation scorer so a
// candidate's era is derived the same way it was learned.
func decadeOf(d pgtype.Date) string {
if !d.Valid {
return ""
}
return fmt.Sprintf("%ds", (d.Time.Year()/10)*10)
}
// 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, eras, moods []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 := qtx.DeleteTasteProfileErasForUser(ctx, userID); err != nil {
return fmt.Errorf("taste: delete eras: %w", err)
}
for _, e := range eras {
if err := qtx.InsertTasteProfileEra(ctx, dbq.InsertTasteProfileEraParams{
UserID: userID, Era: e.Key, Weight: e.Weight,
}); err != nil {
return fmt.Errorf("taste: insert era: %w", err)
}
}
if err := qtx.DeleteTasteProfileMoodsForUser(ctx, userID); err != nil {
return fmt.Errorf("taste: delete moods: %w", err)
}
for _, m := range moods {
if err := qtx.InsertTasteProfileMood(ctx, dbq.InsertTasteProfileMoodParams{
UserID: userID, Mood: m.Key, Weight: m.Weight,
}); err != nil {
return fmt.Errorf("taste: insert mood: %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
}