feat(taste): mood taste facet — #1534
test-go / test (push) Successful in 34s
test-web / test (push) Successful in 40s
test-go / integration (push) Successful in 4m44s

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:
2026-07-14 10:32:26 -04:00
parent 199fec2058
commit f0c08e7326
22 changed files with 482 additions and 48 deletions
@@ -52,6 +52,7 @@ type tasteTuningResp struct {
EngagementFull float64 `json:"engagement_full"`
EnrichedTagScale float64 `json:"enriched_tag_scale"`
EraScale float64 `json:"era_scale"`
MoodScale float64 `json:"mood_scale"`
}
func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
@@ -62,6 +63,7 @@ func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
EngagementFull: t.EngagementFull,
EnrichedTagScale: t.EnrichedTagScale,
EraScale: t.EraScale,
MoodScale: t.MoodScale,
}
}
+8
View File
@@ -562,6 +562,13 @@ type TasteProfileEra struct {
UpdatedAt pgtype.Timestamptz
}
type TasteProfileMood struct {
UserID pgtype.UUID
Mood string
Weight float64
UpdatedAt pgtype.Timestamptz
}
type TasteProfileTag struct {
UserID pgtype.UUID
Tag string
@@ -578,6 +585,7 @@ type TasteTuning struct {
UpdatedAt pgtype.Timestamptz
EnrichedTagScale float64
EraScale float64
MoodScale float64
}
type Track struct {
+12 -4
View File
@@ -10,7 +10,7 @@ import (
)
const getTasteTuning = `-- name: GetTasteTuning :one
SELECT singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at, enriched_tag_scale, era_scale FROM taste_tuning WHERE singleton = true
SELECT singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at, enriched_tag_scale, era_scale, mood_scale FROM taste_tuning WHERE singleton = true
`
func (q *Queries) GetTasteTuning(ctx context.Context) (TasteTuning, error) {
@@ -25,6 +25,7 @@ func (q *Queries) GetTasteTuning(ctx context.Context) (TasteTuning, error) {
&i.UpdatedAt,
&i.EnrichedTagScale,
&i.EraScale,
&i.MoodScale,
)
return i, err
}
@@ -125,9 +126,10 @@ UPDATE taste_tuning
engagement_full = $4,
enriched_tag_scale = $5,
era_scale = $6,
mood_scale = $7,
updated_at = now()
WHERE singleton = true
RETURNING singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at, enriched_tag_scale, era_scale
RETURNING singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at, enriched_tag_scale, era_scale, mood_scale
`
type UpdateTasteTuningParams struct {
@@ -137,6 +139,7 @@ type UpdateTasteTuningParams struct {
EngagementFull float64
EnrichedTagScale float64
EraScale float64
MoodScale float64
}
func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningParams) (TasteTuning, error) {
@@ -147,6 +150,7 @@ func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningPa
arg.EngagementFull,
arg.EnrichedTagScale,
arg.EraScale,
arg.MoodScale,
)
var i TasteTuning
err := row.Scan(
@@ -158,6 +162,7 @@ func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningPa
&i.UpdatedAt,
&i.EnrichedTagScale,
&i.EraScale,
&i.MoodScale,
)
return i, err
}
@@ -224,8 +229,9 @@ func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfi
const upsertTasteTuningDefaults = `-- name: UpsertTasteTuningDefaults :exec
INSERT INTO taste_tuning (
singleton, half_life_days, engagement_hard_skip,
engagement_neutral, engagement_full, enriched_tag_scale, era_scale
) VALUES (true, $1, $2, $3, $4, $5, $6)
engagement_neutral, engagement_full, enriched_tag_scale, era_scale,
mood_scale
) VALUES (true, $1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (singleton) DO NOTHING
`
@@ -236,6 +242,7 @@ type UpsertTasteTuningDefaultsParams struct {
EngagementFull float64
EnrichedTagScale float64
EraScale float64
MoodScale float64
}
func (q *Queries) UpsertTasteTuningDefaults(ctx context.Context, arg UpsertTasteTuningDefaultsParams) error {
@@ -246,6 +253,7 @@ func (q *Queries) UpsertTasteTuningDefaults(ctx context.Context, arg UpsertTaste
arg.EngagementFull,
arg.EnrichedTagScale,
arg.EraScale,
arg.MoodScale,
)
return err
}
+64
View File
@@ -29,6 +29,15 @@ func (q *Queries) DeleteTasteProfileErasForUser(ctx context.Context, userID pgty
return err
}
const deleteTasteProfileMoodsForUser = `-- name: DeleteTasteProfileMoodsForUser :exec
DELETE FROM taste_profile_moods WHERE user_id = $1
`
func (q *Queries) DeleteTasteProfileMoodsForUser(ctx context.Context, userID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteTasteProfileMoodsForUser, userID)
return err
}
const deleteTasteProfileTagsForUser = `-- name: DeleteTasteProfileTagsForUser :exec
DELETE FROM taste_profile_tags WHERE user_id = $1
`
@@ -70,6 +79,22 @@ func (q *Queries) InsertTasteProfileEra(ctx context.Context, arg InsertTasteProf
return err
}
const insertTasteProfileMood = `-- name: InsertTasteProfileMood :exec
INSERT INTO taste_profile_moods (user_id, mood, weight)
VALUES ($1, $2, $3)
`
type InsertTasteProfileMoodParams struct {
UserID pgtype.UUID
Mood string
Weight float64
}
func (q *Queries) InsertTasteProfileMood(ctx context.Context, arg InsertTasteProfileMoodParams) error {
_, err := q.db.Exec(ctx, insertTasteProfileMood, arg.UserID, arg.Mood, arg.Weight)
return err
}
const insertTasteProfileTag = `-- name: InsertTasteProfileTag :exec
INSERT INTO taste_profile_tags (user_id, tag, weight)
VALUES ($1, $2, $3)
@@ -304,6 +329,45 @@ func (q *Queries) ListTasteProfileErasForUser(ctx context.Context, arg ListTaste
return items, nil
}
const listTasteProfileMoodsForUser = `-- name: ListTasteProfileMoodsForUser :many
SELECT mood, weight
FROM taste_profile_moods
WHERE user_id = $1
ORDER BY weight DESC
LIMIT $2
`
type ListTasteProfileMoodsForUserParams struct {
UserID pgtype.UUID
Limit int32
}
type ListTasteProfileMoodsForUserRow struct {
Mood string
Weight float64
}
// Top-weighted taste moods (#1534); consumed by the scorer's mood term.
func (q *Queries) ListTasteProfileMoodsForUser(ctx context.Context, arg ListTasteProfileMoodsForUserParams) ([]ListTasteProfileMoodsForUserRow, error) {
rows, err := q.db.Query(ctx, listTasteProfileMoodsForUser, arg.UserID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListTasteProfileMoodsForUserRow
for rows.Next() {
var i ListTasteProfileMoodsForUserRow
if err := rows.Scan(&i.Mood, &i.Weight); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTasteProfileTagsForUser = `-- name: ListTasteProfileTagsForUser :many
SELECT tag, weight
FROM taste_profile_tags
+34
View File
@@ -110,6 +110,40 @@ func (q *Queries) ListPlayedTrackTagsForUser(ctx context.Context, arg ListPlayed
return items, nil
}
const listTrackTagsForTracks = `-- name: ListTrackTagsForTracks :many
SELECT tt.track_id, tt.tag
FROM track_tags tt
WHERE tt.track_id = ANY($1::uuid[])
`
type ListTrackTagsForTracksRow struct {
TrackID pgtype.UUID
Tag string
}
// Enriched tags for a set of candidate tracks (#1534), so the scorer can
// derive each candidate's mood buckets at scoring time. One row per (track,
// tag); the caller filters to mood words via the internal/mood vocabulary.
func (q *Queries) ListTrackTagsForTracks(ctx context.Context, dollar_1 []pgtype.UUID) ([]ListTrackTagsForTracksRow, error) {
rows, err := q.db.Query(ctx, listTrackTagsForTracks, dollar_1)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListTrackTagsForTracksRow
for rows.Next() {
var i ListTrackTagsForTracksRow
if err := rows.Scan(&i.TrackID, &i.Tag); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTracksMissingTags = `-- name: ListTracksMissingTags :many
SELECT t.id, t.mbid, t.title, a.name AS artist_name, a.mbid AS artist_mbid
@@ -0,0 +1,2 @@
ALTER TABLE taste_tuning DROP COLUMN IF EXISTS mood_scale;
DROP TABLE IF EXISTS taste_profile_moods;
@@ -0,0 +1,25 @@
-- 0047_taste_profile_moods.up.sql — mood taste facet (#1534, milestone #160
-- Opt 2b). A fourth taste facet alongside artists + tags + eras: signed weights
-- over canonical mood buckets (melancholic / energetic / chill / …) derived
-- from a track's enriched folksonomy tags (#1490) via the internal/mood
-- vocabulary, rebuilt daily. Weight is a signed float — positive = drawn to
-- that mood, negative = passively avoided. Coverage is partial (grows with tag
-- enrichment), so it's a supplement, not a foundation. Mirrors
-- taste_profile_tags: CASCADE on user delete, indexed by (user_id, weight DESC).
CREATE TABLE taste_profile_moods (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
mood text NOT NULL,
weight double precision NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, mood)
);
CREATE INDEX taste_profile_moods_user_weight_idx
ON taste_profile_moods (user_id, weight DESC);
-- Mood-facet build knob, tunable in the lab (mirrors era_scale / enriched_tag_
-- scale): scales how strongly a mood-tagged play imprints on the profile.
-- DEFAULT 0.5 backfills the existing singleton and matches
-- taste.DefaultConfig().MoodScale; 0 disables the mood facet entirely.
ALTER TABLE taste_tuning
ADD COLUMN mood_scale double precision NOT NULL DEFAULT 0.5;
@@ -32,8 +32,9 @@ RETURNING *;
-- name: UpsertTasteTuningDefaults :exec
INSERT INTO taste_tuning (
singleton, half_life_days, engagement_hard_skip,
engagement_neutral, engagement_full, enriched_tag_scale, era_scale
) VALUES (true, $1, $2, $3, $4, $5, $6)
engagement_neutral, engagement_full, enriched_tag_scale, era_scale,
mood_scale
) VALUES (true, $1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (singleton) DO NOTHING;
-- name: GetTasteTuning :one
@@ -47,6 +48,7 @@ UPDATE taste_tuning
engagement_full = $4,
enriched_tag_scale = $5,
era_scale = $6,
mood_scale = $7,
updated_at = now()
WHERE singleton = true
RETURNING *;
+15
View File
@@ -85,3 +85,18 @@ FROM taste_profile_eras
WHERE user_id = $1
ORDER BY weight DESC
LIMIT $2;
-- name: DeleteTasteProfileMoodsForUser :exec
DELETE FROM taste_profile_moods WHERE user_id = $1;
-- name: InsertTasteProfileMood :exec
INSERT INTO taste_profile_moods (user_id, mood, weight)
VALUES ($1, $2, $3);
-- name: ListTasteProfileMoodsForUser :many
-- Top-weighted taste moods (#1534); consumed by the scorer's mood term.
SELECT mood, weight
FROM taste_profile_moods
WHERE user_id = $1
ORDER BY weight DESC
LIMIT $2;
+8
View File
@@ -56,3 +56,11 @@ SELECT tt.track_id, tt.tag, tt.weight
FROM track_tags tt
JOIN general_likes gl ON gl.track_id = tt.track_id
WHERE gl.user_id = $1;
-- name: ListTrackTagsForTracks :many
-- Enriched tags for a set of candidate tracks (#1534), so the scorer can
-- derive each candidate's mood buckets at scoring time. One row per (track,
-- tag); the caller filters to mood words via the internal/mood vocabulary.
SELECT tt.track_id, tt.tag
FROM track_tags tt
WHERE tt.track_id = ANY($1::uuid[]);
+58
View File
@@ -0,0 +1,58 @@
// Package mood maps folksonomy tags (from the track_tags cache, #1490) to a
// small set of canonical mood buckets (#1534, milestone #160 Opt 2b). Both the
// taste-profile builder and the recommendation scorer classify a track's tags
// through Of() so a track's mood is derived the same way on both sides.
//
// The vocabulary is intentionally narrow: only words that clearly denote mood
// (not genre). A track with no mood-word tags contributes nothing — the mood
// facet is a supplement whose coverage grows with folksonomy enrichment
// (thin on MusicBrainz-only, richer once Last.fm is keyed).
package mood
import "strings"
// vocab maps a lowercased folksonomy tag to its canonical mood bucket. Several
// synonyms collapse to one bucket so "sad" / "melancholy" / "melancholic" all
// score the same dimension.
var vocab = map[string]string{
// melancholic
"melancholic": "melancholic", "melancholy": "melancholic", "sad": "melancholic",
"sombre": "melancholic", "somber": "melancholic", "wistful": "melancholic",
"bittersweet": "melancholic", "mournful": "melancholic",
// energetic
"energetic": "energetic", "energy": "energetic", "upbeat": "energetic",
"uplifting": "energetic", "party": "energetic", "anthemic": "energetic",
// chill
"chill": "chill", "chillout": "chill", "chilled": "chill", "mellow": "chill",
"relaxing": "chill", "relaxed": "chill", "calm": "chill", "laid-back": "chill",
"laidback": "chill", "soothing": "chill",
// aggressive
"aggressive": "aggressive", "angry": "aggressive", "intense": "aggressive",
"heavy": "aggressive", "brutal": "aggressive",
// dark
"dark": "dark", "moody": "dark", "brooding": "dark", "haunting": "dark", "eerie": "dark",
// dreamy
"dreamy": "dreamy", "ethereal": "dreamy", "atmospheric": "dreamy",
"ambient": "dreamy", "hypnotic": "dreamy",
// happy
"happy": "happy", "cheerful": "happy", "feel good": "happy", "feel-good": "happy",
"feelgood": "happy", "joyful": "happy", "fun": "happy",
// romantic
"romantic": "romantic", "sensual": "romantic", "sexy": "romantic", "sentimental": "romantic",
}
// Of returns the distinct canonical mood buckets present in tags, lowercasing +
// trimming each tag before lookup. Returns nil when no tag maps to a mood, so
// callers get no mood signal for a track with only genre/descriptive tags.
func Of(tags []string) []string {
var out []string
seen := make(map[string]bool)
for _, t := range tags {
key := strings.ToLower(strings.TrimSpace(t))
if m, ok := vocab[key]; ok && !seen[m] {
seen[m] = true
out = append(out, m)
}
}
return out
}
+43
View File
@@ -0,0 +1,43 @@
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)
}
}
+43 -2
View File
@@ -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
+39 -13
View File
@@ -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
+32 -11
View File
@@ -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)
}
}
+3
View File
@@ -137,6 +137,8 @@ func applyTastePatch(current TasteTuning, patch map[string]float64) (TasteTuning
target = &next.EnrichedTagScale
case "era_scale":
target = &next.EraScale
case "mood_scale":
target = &next.MoodScale
default:
return current, nil, fmt.Errorf("%w: %q", ErrUnknownField, field)
}
@@ -184,5 +186,6 @@ func diffTaste(a, b TasteTuning) []fieldChange {
add("engagement_full", a.EngagementFull, b.EngagementFull)
add("enriched_tag_scale", a.EnrichedTagScale, b.EnrichedTagScale)
add("era_scale", a.EraScale, b.EraScale)
add("mood_scale", a.MoodScale, b.MoodScale)
return out
}
+6
View File
@@ -48,6 +48,7 @@ type TasteTuning struct {
EngagementFull float64
EnrichedTagScale float64
EraScale float64
MoodScale float64
}
// ShippedRadioWeights are the shipped radio-profile defaults (moved
@@ -96,6 +97,7 @@ func ShippedTasteTuning() TasteTuning {
EngagementFull: d.Engagement.FullCompletion,
EnrichedTagScale: d.EnrichedTagScale,
EraScale: d.EraScale,
MoodScale: d.MoodScale,
}
}
@@ -145,6 +147,7 @@ func (s *Service) reconcile(ctx context.Context) error {
EngagementFull: st.EngagementFull,
EnrichedTagScale: st.EnrichedTagScale,
EraScale: st.EraScale,
MoodScale: st.MoodScale,
}); err != nil {
return fmt.Errorf("seed taste tuning: %w", err)
}
@@ -170,6 +173,7 @@ func (s *Service) reconcile(ctx context.Context) error {
EngagementFull: tt.EngagementFull,
EnrichedTagScale: tt.EnrichedTagScale,
EraScale: tt.EraScale,
MoodScale: tt.MoodScale,
}
s.mu.Unlock()
@@ -221,6 +225,7 @@ func (s *Service) TasteConfig() taste.Config {
}
cfg.EnrichedTagScale = t.EnrichedTagScale
cfg.EraScale = t.EraScale
cfg.MoodScale = t.MoodScale
return cfg
}
@@ -319,6 +324,7 @@ func (s *Service) persistTaste(
EngagementFull: t.EngagementFull,
EnrichedTagScale: t.EnrichedTagScale,
EraScale: t.EraScale,
MoodScale: t.MoodScale,
}); err != nil {
return fmt.Errorf("update taste tuning: %w", err)
}
+19
View File
@@ -224,6 +224,25 @@ func TestUpdateTaste_EraScale(t *testing.T) {
}
}
func TestUpdateTaste_MoodScale(t *testing.T) {
pool := newPool(t)
s := newService(t, pool)
if err := s.UpdateTaste(context.Background(),
map[string]float64{"mood_scale": 0.8}); err != nil {
t.Fatalf("UpdateTaste: %v", err)
}
if got := s.Taste().MoodScale; got != 0.8 {
t.Errorf("Taste().MoodScale = %v, want 0.8", got)
}
if got := s.TasteConfig().MoodScale; got != 0.8 {
t.Errorf("TasteConfig().MoodScale = %v, want 0.8", got)
}
if err := s.UpdateTaste(context.Background(),
map[string]float64{"mood_scale": 1.5}); !errors.Is(err, ErrOutOfRange) {
t.Errorf("err = %v, want ErrOutOfRange for 1.5", err)
}
}
func TestReset_RestoresShippedAndAudits(t *testing.T) {
pool := newPool(t)
s := newService(t, pool)
+61 -15
View File
@@ -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)
}
+1
View File
@@ -23,6 +23,7 @@ export type TasteTuning = {
engagement_full: number;
enriched_tag_scale: number;
era_scale: number;
mood_scale: number;
};
export type TuningScope = 'radio' | 'daily_mix' | 'taste';
+2 -1
View File
@@ -39,7 +39,8 @@
{ key: 'engagement_neutral', label: 'Neutral point', hint: 'Completion at which a play reads 0.' },
{ key: 'engagement_full', label: 'Full point', hint: 'Completion at/above which a play reads +1.' },
{ key: 'enriched_tag_scale', label: 'Enriched tag weight', hint: 'How much folksonomy tags (MusicBrainz/Last.fm) count vs raw file genre, in [0, 1]. 0 = genre only.' },
{ key: 'era_scale', label: 'Era weight', hint: 'How strongly a decade-play imprints on the era facet, in [0, 1]. 0 = era ignored.' }
{ key: 'era_scale', label: 'Era weight', hint: 'How strongly a decade-play imprints on the era facet, in [0, 1]. 0 = era ignored.' },
{ key: 'mood_scale', label: 'Mood weight', hint: 'How strongly a mood-tagged play imprints on the mood facet (from folksonomy tags), in [0, 1]. 0 = mood ignored.' }
];
const profileScopes: { scope: 'radio' | 'daily_mix'; label: string; blurb: string }[] = [
@@ -39,6 +39,7 @@ const taste = (over: Partial<Record<string, number>> = {}) => ({
engagement_full: 0.9,
enriched_tag_scale: 0.5,
era_scale: 0.5,
mood_scale: 0.5,
...over
});