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>
This commit is contained in:
+61
-15
@@ -16,6 +16,7 @@ import (
|
||||
"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
|
||||
@@ -54,6 +55,13 @@ type Config struct {
|
||||
// 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
|
||||
@@ -75,6 +83,8 @@ type Config struct {
|
||||
MaxNegTags int
|
||||
MaxEras int
|
||||
MaxNegEras int
|
||||
MaxMoods int
|
||||
MaxNegMoods int
|
||||
}
|
||||
|
||||
// DefaultConfig returns the tuned starting configuration.
|
||||
@@ -88,6 +98,7 @@ func DefaultConfig() Config {
|
||||
TagLikeBonus: 0.5,
|
||||
EnrichedTagScale: 0.5,
|
||||
EraScale: 0.5,
|
||||
MoodScale: 0.5,
|
||||
ArtistFloor: -3.0,
|
||||
TagFloor: -3.0,
|
||||
WeightEpsilon: 0.05,
|
||||
@@ -97,6 +108,8 @@ func DefaultConfig() Config {
|
||||
MaxNegTags: 60,
|
||||
MaxEras: 30,
|
||||
MaxNegEras: 15,
|
||||
MaxMoods: 20,
|
||||
MaxNegMoods: 10,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,40 +129,42 @@ func BuildTasteProfile(
|
||||
) error {
|
||||
q := dbq.New(pool)
|
||||
|
||||
artistW, tagW, eraW, err := accumulate(ctx, q, userID, cfg)
|
||||
artistW, tagW, eraW, moodW, err := accumulate(ctx, q, userID, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
applyFloor(artistW, cfg.ArtistFloor)
|
||||
applyFloor(tagW, cfg.TagFloor)
|
||||
// Era shares the tag floor (both are text-keyed facets; a decade that's
|
||||
// been heavily skipped shouldn't dominate as an extreme negative either).
|
||||
// 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); err != nil {
|
||||
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))
|
||||
"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
|
||||
// weight maps.
|
||||
// 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, error) {
|
||||
) (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, fmt.Errorf("taste: load play engagement: %w", err)
|
||||
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
|
||||
@@ -160,13 +175,14 @@ func accumulate(
|
||||
Column2: int32(cfg.WindowDays),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("taste: load played track tags: %w", err)
|
||||
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
|
||||
@@ -175,15 +191,16 @@ func accumulate(
|
||||
}
|
||||
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, fmt.Errorf("taste: load liked tracks: %w", err)
|
||||
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, fmt.Errorf("taste: load liked track tags: %w", err)
|
||||
return nil, nil, nil, nil, fmt.Errorf("taste: load liked track tags: %w", err)
|
||||
}
|
||||
likedTags := groupTagsByTrack(likedTagRows)
|
||||
for _, lt := range likedTracks {
|
||||
@@ -193,16 +210,17 @@ func accumulate(
|
||||
}
|
||||
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, fmt.Errorf("taste: load liked artists: %w", err)
|
||||
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, nil
|
||||
return artistW, tagW, eraW, moodW, nil
|
||||
}
|
||||
|
||||
// groupTagsByTrack buckets flat (track, tag, weight) rows by track id so
|
||||
@@ -241,6 +259,24 @@ func foldEra(eraW map[string]float64, d pgtype.Date, base, scale float64) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -254,7 +290,7 @@ func decadeOf(d pgtype.Date) string {
|
||||
// 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 []weighted[string],
|
||||
artists []weighted[pgtype.UUID], tags, eras, moods []weighted[string],
|
||||
) error {
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
@@ -293,6 +329,16 @@ func persist(
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user