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:
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mood"
|
||||
)
|
||||
|
||||
// LoadCandidates fetches the candidate pool for radio scoring. Combines
|
||||
@@ -62,7 +63,10 @@ func LoadCandidates(
|
||||
PlayCount: int(r.PlayCount),
|
||||
SkipCount: int(r.SkipCount),
|
||||
ContextualMatchScore: ctxScore,
|
||||
TasteMatchScore: profile.Match(r.Track.ArtistID, r.Track.Genre, r.ReleaseDate),
|
||||
// Fallback path: mood is scored only in the primary
|
||||
// (similarity) loader — loading per-candidate tags over this
|
||||
// near-whole-library pool isn't worth it (nil moods → 0).
|
||||
TasteMatchScore: profile.Match(r.Track.ArtistID, r.Track.Genre, r.ReleaseDate, nil),
|
||||
ContextAffinityScore: affinity.Affinity(r.Track.ArtistID),
|
||||
},
|
||||
})
|
||||
@@ -151,6 +155,15 @@ func LoadCandidatesFromSimilarity(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
trackIDs := make([]pgtype.UUID, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
trackIDs = append(trackIDs, r.Track.ID)
|
||||
}
|
||||
moods, err := loadCandidateMoods(ctx, q, trackIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]Candidate, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
var lpt *time.Time
|
||||
@@ -175,7 +188,8 @@ func LoadCandidatesFromSimilarity(
|
||||
SkipCount: int(r.SkipCount),
|
||||
ContextualMatchScore: ctxScore,
|
||||
SimilarityScore: simScore,
|
||||
TasteMatchScore: profile.Match(r.Track.ArtistID, r.Track.Genre, r.ReleaseDate),
|
||||
TasteMatchScore: profile.Match(
|
||||
r.Track.ArtistID, r.Track.Genre, r.ReleaseDate, moods[r.Track.ID]),
|
||||
ContextAffinityScore: affinity.Affinity(r.Track.ArtistID),
|
||||
},
|
||||
})
|
||||
@@ -183,6 +197,33 @@ func LoadCandidatesFromSimilarity(
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// loadCandidateMoods fetches the enriched tags for the given candidate tracks
|
||||
// and reduces each to its canonical mood buckets (internal/mood, #1534), so the
|
||||
// scorer can apply the mood facet per candidate. Tracks with no mood-word tags
|
||||
// are absent from the map (→ no mood signal). Empty input short-circuits.
|
||||
func loadCandidateMoods(
|
||||
ctx context.Context, q *dbq.Queries, trackIDs []pgtype.UUID,
|
||||
) (map[pgtype.UUID][]string, error) {
|
||||
if len(trackIDs) == 0 {
|
||||
return map[pgtype.UUID][]string{}, nil
|
||||
}
|
||||
rows, err := q.ListTrackTagsForTracks(ctx, trackIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tagsByTrack := make(map[pgtype.UUID][]string)
|
||||
for _, r := range rows {
|
||||
tagsByTrack[r.TrackID] = append(tagsByTrack[r.TrackID], r.Tag)
|
||||
}
|
||||
out := make(map[pgtype.UUID][]string, len(tagsByTrack))
|
||||
for id, tags := range tagsByTrack {
|
||||
if m := mood.Of(tags); len(m) > 0 {
|
||||
out[id] = m
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// loadContextualLikesByTrack fetches the user's active contextual_likes in
|
||||
// one query and groups them by track_id. Rows whose session_vector fails
|
||||
// to unmarshal are skipped with no error (don't poison scoring over one
|
||||
|
||||
@@ -16,29 +16,32 @@ import (
|
||||
// rest toward zero (as a per-user max-normalisation would). A weight at the
|
||||
// scale value maps to tanh(1) ≈ 0.76 — "clearly a preference."
|
||||
//
|
||||
// The era term (#1530) is added on TOP of artist+tag (shares don't sum to 1)
|
||||
// so it's a pure decade nudge that never weakens the existing artist/tag
|
||||
// signal when a track is undated; clampUnit bounds the combined result. Its
|
||||
// share is small — a decade is a coarse signal — and re-bakeable if the lab
|
||||
// shows it should carry more.
|
||||
// The era (#1530) and mood (#1534) terms are added on TOP of artist+tag
|
||||
// (shares don't sum to 1) so they're pure nudges that never weaken the existing
|
||||
// artist/tag signal when a track lacks a date or mood tags; clampUnit bounds
|
||||
// the combined result. Their shares are small — both are coarse, partial-
|
||||
// coverage signals — and re-bakeable if the lab shows they should carry more.
|
||||
const (
|
||||
tasteArtistScale = 4.0
|
||||
tasteTagScale = 3.0
|
||||
tasteEraScale = 4.0
|
||||
tasteMoodScale = 3.0
|
||||
tasteArtistShare = 0.7
|
||||
tasteTagShare = 0.3
|
||||
tasteEraShare = 0.15
|
||||
tasteMoodShare = 0.12
|
||||
tasteProfileLimit = 2000 // read cap; profiles are size-capped on write
|
||||
)
|
||||
|
||||
// TasteProfile is the read-side view of a user's learned taste: signed
|
||||
// weights over artists, genre tags, and decade/era buckets. The zero value
|
||||
// (and any unknown artist/tag/era) contributes 0, so cold-start users get no
|
||||
// taste effect.
|
||||
// TasteProfile is the read-side view of a user's learned taste: signed weights
|
||||
// over artists, genre tags, decade/era buckets, and mood buckets. The zero
|
||||
// value (and any unknown key) contributes 0, so cold-start users get no taste
|
||||
// effect.
|
||||
type TasteProfile struct {
|
||||
artists map[pgtype.UUID]float64
|
||||
tags map[string]float64
|
||||
eras map[string]float64
|
||||
moods map[string]float64
|
||||
}
|
||||
|
||||
// LoadTasteProfile reads the user's taste profile from the taste_profile_*
|
||||
@@ -63,10 +66,17 @@ func LoadTasteProfile(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) (
|
||||
if err != nil {
|
||||
return TasteProfile{}, err
|
||||
}
|
||||
moods, err := q.ListTasteProfileMoodsForUser(ctx, dbq.ListTasteProfileMoodsForUserParams{
|
||||
UserID: userID, Limit: tasteProfileLimit,
|
||||
})
|
||||
if err != nil {
|
||||
return TasteProfile{}, err
|
||||
}
|
||||
p := TasteProfile{
|
||||
artists: make(map[pgtype.UUID]float64, len(arts)),
|
||||
tags: make(map[string]float64, len(tags)),
|
||||
eras: make(map[string]float64, len(eras)),
|
||||
moods: make(map[string]float64, len(moods)),
|
||||
}
|
||||
for _, a := range arts {
|
||||
p.artists[a.ArtistID] = a.Weight
|
||||
@@ -77,13 +87,20 @@ func LoadTasteProfile(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) (
|
||||
for _, e := range eras {
|
||||
p.eras[e.Era] = e.Weight
|
||||
}
|
||||
for _, m := range moods {
|
||||
p.moods[m.Mood] = m.Weight
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Match scores a candidate track's fit to the profile in [-1, +1]: a blend of
|
||||
// the artist's taste weight, the average of its genre tags' weights, and its
|
||||
// decade/era weight, each tanh-squashed. Absent artist/tags/era contribute 0.
|
||||
func (p TasteProfile) Match(artistID pgtype.UUID, genre *string, releaseDate pgtype.Date) float64 {
|
||||
// the artist's taste weight, the average of its genre tags' weights, its
|
||||
// decade/era weight, and the average of its mood buckets' weights, each
|
||||
// tanh-squashed. moods are the candidate's canonical mood buckets (from
|
||||
// internal/mood; nil when unknown). Absent artist/tags/era/moods contribute 0.
|
||||
func (p TasteProfile) Match(
|
||||
artistID pgtype.UUID, genre *string, releaseDate pgtype.Date, moods []string,
|
||||
) float64 {
|
||||
a := math.Tanh(p.artists[artistID] / tasteArtistScale)
|
||||
|
||||
var tg float64
|
||||
@@ -102,7 +119,16 @@ func (p TasteProfile) Match(artistID pgtype.UUID, genre *string, releaseDate pgt
|
||||
if decade := decadeOf(releaseDate); decade != "" {
|
||||
er = math.Tanh(p.eras[decade] / tasteEraScale)
|
||||
}
|
||||
return clampUnit(tasteArtistShare*a + tasteTagShare*tg + tasteEraShare*er)
|
||||
|
||||
var mo float64
|
||||
if len(moods) > 0 {
|
||||
var sum float64
|
||||
for _, m := range moods {
|
||||
sum += p.moods[m]
|
||||
}
|
||||
mo = math.Tanh((sum / float64(len(moods))) / tasteMoodScale)
|
||||
}
|
||||
return clampUnit(tasteArtistShare*a + tasteTagShare*tg + tasteEraShare*er + tasteMoodShare*mo)
|
||||
}
|
||||
|
||||
// decadeOf maps an album release date to a decade bucket ("1990s"), or "" for
|
||||
|
||||
@@ -34,23 +34,23 @@ func TestTasteProfile_Match(t *testing.T) {
|
||||
tags: map[string]float64{"Jazz": 6.0, "Noise": -6.0},
|
||||
}
|
||||
|
||||
if m := p.Match(loved, strPtr("Jazz"), noDate()); m <= 0.5 {
|
||||
if m := p.Match(loved, strPtr("Jazz"), noDate(), nil); m <= 0.5 {
|
||||
t.Errorf("loved artist + loved tag = %.3f, want strongly positive", m)
|
||||
}
|
||||
if m := p.Match(disliked, strPtr("Noise"), noDate()); m >= -0.5 {
|
||||
if m := p.Match(disliked, strPtr("Noise"), noDate(), nil); m >= -0.5 {
|
||||
t.Errorf("disliked artist + disliked tag = %.3f, want strongly negative", m)
|
||||
}
|
||||
if m := p.Match(unknown, nil, noDate()); m != 0 {
|
||||
if m := p.Match(unknown, nil, noDate(), nil); m != 0 {
|
||||
t.Errorf("unknown artist, no genre = %.3f, want 0", m)
|
||||
}
|
||||
// Artist dominates (0.7 share): loved artist with an unknown tag is still
|
||||
// clearly positive.
|
||||
if m := p.Match(loved, strPtr("Unheard"), noDate()); m <= 0 {
|
||||
if m := p.Match(loved, strPtr("Unheard"), noDate(), nil); m <= 0 {
|
||||
t.Errorf("loved artist + unknown tag = %.3f, want positive", m)
|
||||
}
|
||||
// Output stays within [-1, 1] even with saturated inputs.
|
||||
for _, a := range []pgtype.UUID{loved, disliked, unknown} {
|
||||
m := p.Match(a, strPtr("Jazz"), dateInYear(1994))
|
||||
m := p.Match(a, strPtr("Jazz"), dateInYear(1994), nil)
|
||||
if m < -1 || m > 1 {
|
||||
t.Errorf("Match out of [-1,1]: %.3f", m)
|
||||
}
|
||||
@@ -67,22 +67,43 @@ func TestTasteProfile_EraTerm(t *testing.T) {
|
||||
tags: map[string]float64{},
|
||||
eras: map[string]float64{"1990s": 8.0, "1980s": -8.0},
|
||||
}
|
||||
loved := p.Match(art, nil, dateInYear(1994))
|
||||
loved := p.Match(art, nil, dateInYear(1994), nil)
|
||||
if loved <= 0 {
|
||||
t.Errorf("loved era (1990s) = %.3f, want positive", loved)
|
||||
}
|
||||
disliked := p.Match(art, nil, dateInYear(1987))
|
||||
disliked := p.Match(art, nil, dateInYear(1987), nil)
|
||||
if disliked >= 0 {
|
||||
t.Errorf("disliked era (1980s) = %.3f, want negative", disliked)
|
||||
}
|
||||
if m := p.Match(art, nil, noDate()); m != 0 {
|
||||
if m := p.Match(art, nil, noDate(), nil); m != 0 {
|
||||
t.Errorf("undated track = %.3f, want 0 (no era contribution)", m)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTasteProfile_MoodTerm verifies the mood facet nudges the match: with
|
||||
// artist/genre/era neutral, a loved mood lifts the score and a disliked mood
|
||||
// lowers it, while a track with no mood buckets is unaffected.
|
||||
func TestTasteProfile_MoodTerm(t *testing.T) {
|
||||
art := uuidN(1)
|
||||
p := TasteProfile{
|
||||
artists: map[pgtype.UUID]float64{},
|
||||
tags: map[string]float64{},
|
||||
moods: map[string]float64{"chill": 8.0, "aggressive": -8.0},
|
||||
}
|
||||
if m := p.Match(art, nil, noDate(), []string{"chill"}); m <= 0 {
|
||||
t.Errorf("loved mood (chill) = %.3f, want positive", m)
|
||||
}
|
||||
if m := p.Match(art, nil, noDate(), []string{"aggressive"}); m >= 0 {
|
||||
t.Errorf("disliked mood (aggressive) = %.3f, want negative", m)
|
||||
}
|
||||
if m := p.Match(art, nil, noDate(), nil); m != 0 {
|
||||
t.Errorf("no moods = %.3f, want 0 (no mood contribution)", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTasteProfile_EmptyIsNeutral(t *testing.T) {
|
||||
var p TasteProfile // zero value: nil maps
|
||||
if m := p.Match(uuidN(1), strPtr("Jazz"), dateInYear(1994)); m != 0 {
|
||||
if m := p.Match(uuidN(1), strPtr("Jazz"), dateInYear(1994), nil); m != 0 {
|
||||
t.Errorf("empty profile Match = %.3f, want 0 (cold start neutral)", m)
|
||||
}
|
||||
}
|
||||
@@ -131,10 +152,10 @@ func TestLoadTasteProfile_RoundTrip(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if m := p.Match(art.ID, strPtr("Jazz"), noDate()); m <= 0.5 {
|
||||
if m := p.Match(art.ID, strPtr("Jazz"), noDate(), nil); m <= 0.5 {
|
||||
t.Errorf("round-trip Match = %.3f, want strongly positive", m)
|
||||
}
|
||||
if m := p.Match(uuidN(9), nil, noDate()); m != 0 {
|
||||
if m := p.Match(uuidN(9), nil, noDate(), nil); m != 0 {
|
||||
t.Errorf("absent artist Match = %.3f, want 0", m)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user