From f0c08e7326eec73f025e7ce24e2ebf0509ee5313 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 14 Jul 2026 10:32:26 -0400 Subject: [PATCH] =?UTF-8?q?feat(taste):=20mood=20taste=20facet=20=E2=80=94?= =?UTF-8?q?=20#1534?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- internal/api/admin_recommendation_tuning.go | 2 + internal/db/dbq/models.go | 8 ++ internal/db/dbq/recommendation_tuning.sql.go | 16 +++- internal/db/dbq/taste_profile.sql.go | 64 ++++++++++++++++ internal/db/dbq/track_tags.sql.go | 34 +++++++++ .../0047_taste_profile_moods.down.sql | 2 + .../0047_taste_profile_moods.up.sql | 25 ++++++ internal/db/queries/recommendation_tuning.sql | 6 +- internal/db/queries/taste_profile.sql | 15 ++++ internal/db/queries/track_tags.sql | 8 ++ internal/mood/mood.go | 58 ++++++++++++++ internal/mood/mood_test.go | 43 +++++++++++ internal/recommendation/candidates.go | 45 ++++++++++- internal/recommendation/taste.go | 52 +++++++++---- internal/recommendation/taste_test.go | 43 ++++++++--- internal/recsettings/patch.go | 3 + internal/recsettings/service.go | 6 ++ internal/recsettings/service_test.go | 19 +++++ internal/taste/profile.go | 76 +++++++++++++++---- web/src/lib/api/tuning.ts | 1 + web/src/routes/admin/tuning/+page.svelte | 3 +- web/src/routes/admin/tuning/tuning.test.ts | 1 + 22 files changed, 482 insertions(+), 48 deletions(-) create mode 100644 internal/db/migrations/0047_taste_profile_moods.down.sql create mode 100644 internal/db/migrations/0047_taste_profile_moods.up.sql create mode 100644 internal/mood/mood.go create mode 100644 internal/mood/mood_test.go diff --git a/internal/api/admin_recommendation_tuning.go b/internal/api/admin_recommendation_tuning.go index c10ab012..a97276c5 100644 --- a/internal/api/admin_recommendation_tuning.go +++ b/internal/api/admin_recommendation_tuning.go @@ -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, } } diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index 6fc2258b..6cc0e6b8 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -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 { diff --git a/internal/db/dbq/recommendation_tuning.sql.go b/internal/db/dbq/recommendation_tuning.sql.go index bd1dc787..f90aaa97 100644 --- a/internal/db/dbq/recommendation_tuning.sql.go +++ b/internal/db/dbq/recommendation_tuning.sql.go @@ -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 } diff --git a/internal/db/dbq/taste_profile.sql.go b/internal/db/dbq/taste_profile.sql.go index 929aa7c5..473af444 100644 --- a/internal/db/dbq/taste_profile.sql.go +++ b/internal/db/dbq/taste_profile.sql.go @@ -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 diff --git a/internal/db/dbq/track_tags.sql.go b/internal/db/dbq/track_tags.sql.go index 5c2d78b4..70ddcec4 100644 --- a/internal/db/dbq/track_tags.sql.go +++ b/internal/db/dbq/track_tags.sql.go @@ -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 diff --git a/internal/db/migrations/0047_taste_profile_moods.down.sql b/internal/db/migrations/0047_taste_profile_moods.down.sql new file mode 100644 index 00000000..ae61a7dd --- /dev/null +++ b/internal/db/migrations/0047_taste_profile_moods.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE taste_tuning DROP COLUMN IF EXISTS mood_scale; +DROP TABLE IF EXISTS taste_profile_moods; diff --git a/internal/db/migrations/0047_taste_profile_moods.up.sql b/internal/db/migrations/0047_taste_profile_moods.up.sql new file mode 100644 index 00000000..3d16c380 --- /dev/null +++ b/internal/db/migrations/0047_taste_profile_moods.up.sql @@ -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; diff --git a/internal/db/queries/recommendation_tuning.sql b/internal/db/queries/recommendation_tuning.sql index a627fb5b..b017e542 100644 --- a/internal/db/queries/recommendation_tuning.sql +++ b/internal/db/queries/recommendation_tuning.sql @@ -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 *; diff --git a/internal/db/queries/taste_profile.sql b/internal/db/queries/taste_profile.sql index bbec97e8..d9ddb987 100644 --- a/internal/db/queries/taste_profile.sql +++ b/internal/db/queries/taste_profile.sql @@ -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; diff --git a/internal/db/queries/track_tags.sql b/internal/db/queries/track_tags.sql index d5c6b14f..9449265a 100644 --- a/internal/db/queries/track_tags.sql +++ b/internal/db/queries/track_tags.sql @@ -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[]); diff --git a/internal/mood/mood.go b/internal/mood/mood.go new file mode 100644 index 00000000..7e855643 --- /dev/null +++ b/internal/mood/mood.go @@ -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 +} diff --git a/internal/mood/mood_test.go b/internal/mood/mood_test.go new file mode 100644 index 00000000..3be30e01 --- /dev/null +++ b/internal/mood/mood_test.go @@ -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) + } +} diff --git a/internal/recommendation/candidates.go b/internal/recommendation/candidates.go index 6ddecc34..74151139 100644 --- a/internal/recommendation/candidates.go +++ b/internal/recommendation/candidates.go @@ -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 diff --git a/internal/recommendation/taste.go b/internal/recommendation/taste.go index 67c250d8..46fd37f6 100644 --- a/internal/recommendation/taste.go +++ b/internal/recommendation/taste.go @@ -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 diff --git a/internal/recommendation/taste_test.go b/internal/recommendation/taste_test.go index ada8c27d..3dfd97e8 100644 --- a/internal/recommendation/taste_test.go +++ b/internal/recommendation/taste_test.go @@ -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) } } diff --git a/internal/recsettings/patch.go b/internal/recsettings/patch.go index 43ca51d2..6ae86833 100644 --- a/internal/recsettings/patch.go +++ b/internal/recsettings/patch.go @@ -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 } diff --git a/internal/recsettings/service.go b/internal/recsettings/service.go index d8331ee5..22640847 100644 --- a/internal/recsettings/service.go +++ b/internal/recsettings/service.go @@ -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) } diff --git a/internal/recsettings/service_test.go b/internal/recsettings/service_test.go index abfdf12a..cf96f8a3 100644 --- a/internal/recsettings/service_test.go +++ b/internal/recsettings/service_test.go @@ -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) diff --git a/internal/taste/profile.go b/internal/taste/profile.go index 5a66c146..65fd187a 100644 --- a/internal/taste/profile.go +++ b/internal/taste/profile.go @@ -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) } diff --git a/web/src/lib/api/tuning.ts b/web/src/lib/api/tuning.ts index d27e1af9..fae0f40f 100644 --- a/web/src/lib/api/tuning.ts +++ b/web/src/lib/api/tuning.ts @@ -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'; diff --git a/web/src/routes/admin/tuning/+page.svelte b/web/src/routes/admin/tuning/+page.svelte index 47acd4cc..79e96668 100644 --- a/web/src/routes/admin/tuning/+page.svelte +++ b/web/src/routes/admin/tuning/+page.svelte @@ -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 }[] = [ diff --git a/web/src/routes/admin/tuning/tuning.test.ts b/web/src/routes/admin/tuning/tuning.test.ts index 02ae3e9e..ce0de18d 100644 --- a/web/src/routes/admin/tuning/tuning.test.ts +++ b/web/src/routes/admin/tuning/tuning.test.ts @@ -39,6 +39,7 @@ const taste = (over: Partial> = {}) => ({ engagement_full: 0.9, enriched_tag_scale: 0.5, era_scale: 0.5, + mood_scale: 0.5, ...over });