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>
44 lines
1.1 KiB
Go
44 lines
1.1 KiB
Go
package mood
|
|
|
|
import (
|
|
"sort"
|
|
"testing"
|
|
)
|
|
|
|
func TestOf(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in []string
|
|
want []string
|
|
}{
|
|
{"synonyms collapse", []string{"sad", "melancholy", "melancholic"}, []string{"melancholic"}},
|
|
{"case + whitespace", []string{" CHILL ", "Mellow"}, []string{"chill"}},
|
|
{"multiple buckets", []string{"energetic", "dark"}, []string{"dark", "energetic"}},
|
|
{"genre-only → none", []string{"rock", "shoegaze", "post-punk"}, nil},
|
|
{"empty", nil, nil},
|
|
}
|
|
for _, c := range cases {
|
|
got := Of(c.in)
|
|
sort.Strings(got)
|
|
want := append([]string(nil), c.want...)
|
|
sort.Strings(want)
|
|
if len(got) != len(want) {
|
|
t.Errorf("%s: Of(%v) = %v, want %v", c.name, c.in, got, want)
|
|
continue
|
|
}
|
|
for i := range got {
|
|
if got[i] != want[i] {
|
|
t.Errorf("%s: Of(%v) = %v, want %v", c.name, c.in, got, want)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOf_Dedupes(t *testing.T) {
|
|
// Three tags mapping to the same bucket yield one entry.
|
|
if got := Of([]string{"chill", "mellow", "relaxed"}); len(got) != 1 || got[0] != "chill" {
|
|
t.Errorf("Of dedupe = %v, want [chill]", got)
|
|
}
|
|
}
|