f0c08e7326
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>
423 lines
15 KiB
Go
423 lines
15 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"
|
||
"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
|
||
}
|