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:
@@ -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