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:
@@ -52,6 +52,7 @@ type tasteTuningResp struct {
|
|||||||
EngagementFull float64 `json:"engagement_full"`
|
EngagementFull float64 `json:"engagement_full"`
|
||||||
EnrichedTagScale float64 `json:"enriched_tag_scale"`
|
EnrichedTagScale float64 `json:"enriched_tag_scale"`
|
||||||
EraScale float64 `json:"era_scale"`
|
EraScale float64 `json:"era_scale"`
|
||||||
|
MoodScale float64 `json:"mood_scale"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
|
func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
|
||||||
@@ -62,6 +63,7 @@ func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
|
|||||||
EngagementFull: t.EngagementFull,
|
EngagementFull: t.EngagementFull,
|
||||||
EnrichedTagScale: t.EnrichedTagScale,
|
EnrichedTagScale: t.EnrichedTagScale,
|
||||||
EraScale: t.EraScale,
|
EraScale: t.EraScale,
|
||||||
|
MoodScale: t.MoodScale,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -562,6 +562,13 @@ type TasteProfileEra struct {
|
|||||||
UpdatedAt pgtype.Timestamptz
|
UpdatedAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TasteProfileMood struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
Mood string
|
||||||
|
Weight float64
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
type TasteProfileTag struct {
|
type TasteProfileTag struct {
|
||||||
UserID pgtype.UUID
|
UserID pgtype.UUID
|
||||||
Tag string
|
Tag string
|
||||||
@@ -578,6 +585,7 @@ type TasteTuning struct {
|
|||||||
UpdatedAt pgtype.Timestamptz
|
UpdatedAt pgtype.Timestamptz
|
||||||
EnrichedTagScale float64
|
EnrichedTagScale float64
|
||||||
EraScale float64
|
EraScale float64
|
||||||
|
MoodScale float64
|
||||||
}
|
}
|
||||||
|
|
||||||
type Track struct {
|
type Track struct {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const getTasteTuning = `-- name: GetTasteTuning :one
|
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) {
|
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.UpdatedAt,
|
||||||
&i.EnrichedTagScale,
|
&i.EnrichedTagScale,
|
||||||
&i.EraScale,
|
&i.EraScale,
|
||||||
|
&i.MoodScale,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -125,9 +126,10 @@ UPDATE taste_tuning
|
|||||||
engagement_full = $4,
|
engagement_full = $4,
|
||||||
enriched_tag_scale = $5,
|
enriched_tag_scale = $5,
|
||||||
era_scale = $6,
|
era_scale = $6,
|
||||||
|
mood_scale = $7,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE singleton = true
|
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 {
|
type UpdateTasteTuningParams struct {
|
||||||
@@ -137,6 +139,7 @@ type UpdateTasteTuningParams struct {
|
|||||||
EngagementFull float64
|
EngagementFull float64
|
||||||
EnrichedTagScale float64
|
EnrichedTagScale float64
|
||||||
EraScale float64
|
EraScale float64
|
||||||
|
MoodScale float64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningParams) (TasteTuning, error) {
|
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.EngagementFull,
|
||||||
arg.EnrichedTagScale,
|
arg.EnrichedTagScale,
|
||||||
arg.EraScale,
|
arg.EraScale,
|
||||||
|
arg.MoodScale,
|
||||||
)
|
)
|
||||||
var i TasteTuning
|
var i TasteTuning
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -158,6 +162,7 @@ func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningPa
|
|||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.EnrichedTagScale,
|
&i.EnrichedTagScale,
|
||||||
&i.EraScale,
|
&i.EraScale,
|
||||||
|
&i.MoodScale,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -224,8 +229,9 @@ func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfi
|
|||||||
const upsertTasteTuningDefaults = `-- name: UpsertTasteTuningDefaults :exec
|
const upsertTasteTuningDefaults = `-- name: UpsertTasteTuningDefaults :exec
|
||||||
INSERT INTO taste_tuning (
|
INSERT INTO taste_tuning (
|
||||||
singleton, half_life_days, engagement_hard_skip,
|
singleton, half_life_days, engagement_hard_skip,
|
||||||
engagement_neutral, engagement_full, enriched_tag_scale, era_scale
|
engagement_neutral, engagement_full, enriched_tag_scale, era_scale,
|
||||||
) VALUES (true, $1, $2, $3, $4, $5, $6)
|
mood_scale
|
||||||
|
) VALUES (true, $1, $2, $3, $4, $5, $6, $7)
|
||||||
ON CONFLICT (singleton) DO NOTHING
|
ON CONFLICT (singleton) DO NOTHING
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -236,6 +242,7 @@ type UpsertTasteTuningDefaultsParams struct {
|
|||||||
EngagementFull float64
|
EngagementFull float64
|
||||||
EnrichedTagScale float64
|
EnrichedTagScale float64
|
||||||
EraScale float64
|
EraScale float64
|
||||||
|
MoodScale float64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) UpsertTasteTuningDefaults(ctx context.Context, arg UpsertTasteTuningDefaultsParams) error {
|
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.EngagementFull,
|
||||||
arg.EnrichedTagScale,
|
arg.EnrichedTagScale,
|
||||||
arg.EraScale,
|
arg.EraScale,
|
||||||
|
arg.MoodScale,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ func (q *Queries) DeleteTasteProfileErasForUser(ctx context.Context, userID pgty
|
|||||||
return err
|
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
|
const deleteTasteProfileTagsForUser = `-- name: DeleteTasteProfileTagsForUser :exec
|
||||||
DELETE FROM taste_profile_tags WHERE user_id = $1
|
DELETE FROM taste_profile_tags WHERE user_id = $1
|
||||||
`
|
`
|
||||||
@@ -70,6 +79,22 @@ func (q *Queries) InsertTasteProfileEra(ctx context.Context, arg InsertTasteProf
|
|||||||
return err
|
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
|
const insertTasteProfileTag = `-- name: InsertTasteProfileTag :exec
|
||||||
INSERT INTO taste_profile_tags (user_id, tag, weight)
|
INSERT INTO taste_profile_tags (user_id, tag, weight)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3)
|
||||||
@@ -304,6 +329,45 @@ func (q *Queries) ListTasteProfileErasForUser(ctx context.Context, arg ListTaste
|
|||||||
return items, nil
|
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
|
const listTasteProfileTagsForUser = `-- name: ListTasteProfileTagsForUser :many
|
||||||
SELECT tag, weight
|
SELECT tag, weight
|
||||||
FROM taste_profile_tags
|
FROM taste_profile_tags
|
||||||
|
|||||||
@@ -110,6 +110,40 @@ func (q *Queries) ListPlayedTrackTagsForUser(ctx context.Context, arg ListPlayed
|
|||||||
return items, nil
|
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
|
const listTracksMissingTags = `-- name: ListTracksMissingTags :many
|
||||||
|
|
||||||
SELECT t.id, t.mbid, t.title, a.name AS artist_name, a.mbid AS artist_mbid
|
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
|
-- name: UpsertTasteTuningDefaults :exec
|
||||||
INSERT INTO taste_tuning (
|
INSERT INTO taste_tuning (
|
||||||
singleton, half_life_days, engagement_hard_skip,
|
singleton, half_life_days, engagement_hard_skip,
|
||||||
engagement_neutral, engagement_full, enriched_tag_scale, era_scale
|
engagement_neutral, engagement_full, enriched_tag_scale, era_scale,
|
||||||
) VALUES (true, $1, $2, $3, $4, $5, $6)
|
mood_scale
|
||||||
|
) VALUES (true, $1, $2, $3, $4, $5, $6, $7)
|
||||||
ON CONFLICT (singleton) DO NOTHING;
|
ON CONFLICT (singleton) DO NOTHING;
|
||||||
|
|
||||||
-- name: GetTasteTuning :one
|
-- name: GetTasteTuning :one
|
||||||
@@ -47,6 +48,7 @@ UPDATE taste_tuning
|
|||||||
engagement_full = $4,
|
engagement_full = $4,
|
||||||
enriched_tag_scale = $5,
|
enriched_tag_scale = $5,
|
||||||
era_scale = $6,
|
era_scale = $6,
|
||||||
|
mood_scale = $7,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE singleton = true
|
WHERE singleton = true
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|||||||
@@ -85,3 +85,18 @@ FROM taste_profile_eras
|
|||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
ORDER BY weight DESC
|
ORDER BY weight DESC
|
||||||
LIMIT $2;
|
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;
|
||||||
|
|||||||
@@ -56,3 +56,11 @@ SELECT tt.track_id, tt.tag, tt.weight
|
|||||||
FROM track_tags tt
|
FROM track_tags tt
|
||||||
JOIN general_likes gl ON gl.track_id = tt.track_id
|
JOIN general_likes gl ON gl.track_id = tt.track_id
|
||||||
WHERE gl.user_id = $1;
|
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[]);
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/mood"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LoadCandidates fetches the candidate pool for radio scoring. Combines
|
// LoadCandidates fetches the candidate pool for radio scoring. Combines
|
||||||
@@ -62,7 +63,10 @@ func LoadCandidates(
|
|||||||
PlayCount: int(r.PlayCount),
|
PlayCount: int(r.PlayCount),
|
||||||
SkipCount: int(r.SkipCount),
|
SkipCount: int(r.SkipCount),
|
||||||
ContextualMatchScore: ctxScore,
|
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),
|
ContextAffinityScore: affinity.Affinity(r.Track.ArtistID),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -151,6 +155,15 @@ func LoadCandidatesFromSimilarity(
|
|||||||
return nil, err
|
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))
|
out := make([]Candidate, 0, len(rows))
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
var lpt *time.Time
|
var lpt *time.Time
|
||||||
@@ -175,7 +188,8 @@ func LoadCandidatesFromSimilarity(
|
|||||||
SkipCount: int(r.SkipCount),
|
SkipCount: int(r.SkipCount),
|
||||||
ContextualMatchScore: ctxScore,
|
ContextualMatchScore: ctxScore,
|
||||||
SimilarityScore: simScore,
|
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),
|
ContextAffinityScore: affinity.Affinity(r.Track.ArtistID),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -183,6 +197,33 @@ func LoadCandidatesFromSimilarity(
|
|||||||
return out, nil
|
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
|
// loadContextualLikesByTrack fetches the user's active contextual_likes in
|
||||||
// one query and groups them by track_id. Rows whose session_vector fails
|
// 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
|
// 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
|
// 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."
|
// 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)
|
// The era (#1530) and mood (#1534) terms are added on TOP of artist+tag
|
||||||
// so it's a pure decade nudge that never weakens the existing artist/tag
|
// (shares don't sum to 1) so they're pure nudges that never weaken the existing
|
||||||
// signal when a track is undated; clampUnit bounds the combined result. Its
|
// artist/tag signal when a track lacks a date or mood tags; clampUnit bounds
|
||||||
// share is small — a decade is a coarse signal — and re-bakeable if the lab
|
// the combined result. Their shares are small — both are coarse, partial-
|
||||||
// shows it should carry more.
|
// coverage signals — and re-bakeable if the lab shows they should carry more.
|
||||||
const (
|
const (
|
||||||
tasteArtistScale = 4.0
|
tasteArtistScale = 4.0
|
||||||
tasteTagScale = 3.0
|
tasteTagScale = 3.0
|
||||||
tasteEraScale = 4.0
|
tasteEraScale = 4.0
|
||||||
|
tasteMoodScale = 3.0
|
||||||
tasteArtistShare = 0.7
|
tasteArtistShare = 0.7
|
||||||
tasteTagShare = 0.3
|
tasteTagShare = 0.3
|
||||||
tasteEraShare = 0.15
|
tasteEraShare = 0.15
|
||||||
|
tasteMoodShare = 0.12
|
||||||
tasteProfileLimit = 2000 // read cap; profiles are size-capped on write
|
tasteProfileLimit = 2000 // read cap; profiles are size-capped on write
|
||||||
)
|
)
|
||||||
|
|
||||||
// TasteProfile is the read-side view of a user's learned taste: signed
|
// TasteProfile is the read-side view of a user's learned taste: signed weights
|
||||||
// weights over artists, genre tags, and decade/era buckets. The zero value
|
// over artists, genre tags, decade/era buckets, and mood buckets. The zero
|
||||||
// (and any unknown artist/tag/era) contributes 0, so cold-start users get no
|
// value (and any unknown key) contributes 0, so cold-start users get no taste
|
||||||
// taste effect.
|
// effect.
|
||||||
type TasteProfile struct {
|
type TasteProfile struct {
|
||||||
artists map[pgtype.UUID]float64
|
artists map[pgtype.UUID]float64
|
||||||
tags map[string]float64
|
tags map[string]float64
|
||||||
eras map[string]float64
|
eras map[string]float64
|
||||||
|
moods map[string]float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadTasteProfile reads the user's taste profile from the taste_profile_*
|
// 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 {
|
if err != nil {
|
||||||
return TasteProfile{}, err
|
return TasteProfile{}, err
|
||||||
}
|
}
|
||||||
|
moods, err := q.ListTasteProfileMoodsForUser(ctx, dbq.ListTasteProfileMoodsForUserParams{
|
||||||
|
UserID: userID, Limit: tasteProfileLimit,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return TasteProfile{}, err
|
||||||
|
}
|
||||||
p := TasteProfile{
|
p := TasteProfile{
|
||||||
artists: make(map[pgtype.UUID]float64, len(arts)),
|
artists: make(map[pgtype.UUID]float64, len(arts)),
|
||||||
tags: make(map[string]float64, len(tags)),
|
tags: make(map[string]float64, len(tags)),
|
||||||
eras: make(map[string]float64, len(eras)),
|
eras: make(map[string]float64, len(eras)),
|
||||||
|
moods: make(map[string]float64, len(moods)),
|
||||||
}
|
}
|
||||||
for _, a := range arts {
|
for _, a := range arts {
|
||||||
p.artists[a.ArtistID] = a.Weight
|
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 {
|
for _, e := range eras {
|
||||||
p.eras[e.Era] = e.Weight
|
p.eras[e.Era] = e.Weight
|
||||||
}
|
}
|
||||||
|
for _, m := range moods {
|
||||||
|
p.moods[m.Mood] = m.Weight
|
||||||
|
}
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match scores a candidate track's fit to the profile in [-1, +1]: a blend of
|
// 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
|
// the artist's taste weight, the average of its genre tags' weights, its
|
||||||
// decade/era weight, each tanh-squashed. Absent artist/tags/era contribute 0.
|
// decade/era weight, and the average of its mood buckets' weights, each
|
||||||
func (p TasteProfile) Match(artistID pgtype.UUID, genre *string, releaseDate pgtype.Date) float64 {
|
// 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)
|
a := math.Tanh(p.artists[artistID] / tasteArtistScale)
|
||||||
|
|
||||||
var tg float64
|
var tg float64
|
||||||
@@ -102,7 +119,16 @@ func (p TasteProfile) Match(artistID pgtype.UUID, genre *string, releaseDate pgt
|
|||||||
if decade := decadeOf(releaseDate); decade != "" {
|
if decade := decadeOf(releaseDate); decade != "" {
|
||||||
er = math.Tanh(p.eras[decade] / tasteEraScale)
|
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
|
// 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},
|
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)
|
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)
|
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)
|
t.Errorf("unknown artist, no genre = %.3f, want 0", m)
|
||||||
}
|
}
|
||||||
// Artist dominates (0.7 share): loved artist with an unknown tag is still
|
// Artist dominates (0.7 share): loved artist with an unknown tag is still
|
||||||
// clearly positive.
|
// 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)
|
t.Errorf("loved artist + unknown tag = %.3f, want positive", m)
|
||||||
}
|
}
|
||||||
// Output stays within [-1, 1] even with saturated inputs.
|
// Output stays within [-1, 1] even with saturated inputs.
|
||||||
for _, a := range []pgtype.UUID{loved, disliked, unknown} {
|
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 {
|
if m < -1 || m > 1 {
|
||||||
t.Errorf("Match out of [-1,1]: %.3f", m)
|
t.Errorf("Match out of [-1,1]: %.3f", m)
|
||||||
}
|
}
|
||||||
@@ -67,22 +67,43 @@ func TestTasteProfile_EraTerm(t *testing.T) {
|
|||||||
tags: map[string]float64{},
|
tags: map[string]float64{},
|
||||||
eras: map[string]float64{"1990s": 8.0, "1980s": -8.0},
|
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 {
|
if loved <= 0 {
|
||||||
t.Errorf("loved era (1990s) = %.3f, want positive", loved)
|
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 {
|
if disliked >= 0 {
|
||||||
t.Errorf("disliked era (1980s) = %.3f, want negative", disliked)
|
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)
|
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) {
|
func TestTasteProfile_EmptyIsNeutral(t *testing.T) {
|
||||||
var p TasteProfile // zero value: nil maps
|
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)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("load: %v", err)
|
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)
|
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)
|
t.Errorf("absent artist Match = %.3f, want 0", m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,6 +137,8 @@ func applyTastePatch(current TasteTuning, patch map[string]float64) (TasteTuning
|
|||||||
target = &next.EnrichedTagScale
|
target = &next.EnrichedTagScale
|
||||||
case "era_scale":
|
case "era_scale":
|
||||||
target = &next.EraScale
|
target = &next.EraScale
|
||||||
|
case "mood_scale":
|
||||||
|
target = &next.MoodScale
|
||||||
default:
|
default:
|
||||||
return current, nil, fmt.Errorf("%w: %q", ErrUnknownField, field)
|
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("engagement_full", a.EngagementFull, b.EngagementFull)
|
||||||
add("enriched_tag_scale", a.EnrichedTagScale, b.EnrichedTagScale)
|
add("enriched_tag_scale", a.EnrichedTagScale, b.EnrichedTagScale)
|
||||||
add("era_scale", a.EraScale, b.EraScale)
|
add("era_scale", a.EraScale, b.EraScale)
|
||||||
|
add("mood_scale", a.MoodScale, b.MoodScale)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ type TasteTuning struct {
|
|||||||
EngagementFull float64
|
EngagementFull float64
|
||||||
EnrichedTagScale float64
|
EnrichedTagScale float64
|
||||||
EraScale float64
|
EraScale float64
|
||||||
|
MoodScale float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// ShippedRadioWeights are the shipped radio-profile defaults (moved
|
// ShippedRadioWeights are the shipped radio-profile defaults (moved
|
||||||
@@ -96,6 +97,7 @@ func ShippedTasteTuning() TasteTuning {
|
|||||||
EngagementFull: d.Engagement.FullCompletion,
|
EngagementFull: d.Engagement.FullCompletion,
|
||||||
EnrichedTagScale: d.EnrichedTagScale,
|
EnrichedTagScale: d.EnrichedTagScale,
|
||||||
EraScale: d.EraScale,
|
EraScale: d.EraScale,
|
||||||
|
MoodScale: d.MoodScale,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +147,7 @@ func (s *Service) reconcile(ctx context.Context) error {
|
|||||||
EngagementFull: st.EngagementFull,
|
EngagementFull: st.EngagementFull,
|
||||||
EnrichedTagScale: st.EnrichedTagScale,
|
EnrichedTagScale: st.EnrichedTagScale,
|
||||||
EraScale: st.EraScale,
|
EraScale: st.EraScale,
|
||||||
|
MoodScale: st.MoodScale,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("seed taste tuning: %w", err)
|
return fmt.Errorf("seed taste tuning: %w", err)
|
||||||
}
|
}
|
||||||
@@ -170,6 +173,7 @@ func (s *Service) reconcile(ctx context.Context) error {
|
|||||||
EngagementFull: tt.EngagementFull,
|
EngagementFull: tt.EngagementFull,
|
||||||
EnrichedTagScale: tt.EnrichedTagScale,
|
EnrichedTagScale: tt.EnrichedTagScale,
|
||||||
EraScale: tt.EraScale,
|
EraScale: tt.EraScale,
|
||||||
|
MoodScale: tt.MoodScale,
|
||||||
}
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
@@ -221,6 +225,7 @@ func (s *Service) TasteConfig() taste.Config {
|
|||||||
}
|
}
|
||||||
cfg.EnrichedTagScale = t.EnrichedTagScale
|
cfg.EnrichedTagScale = t.EnrichedTagScale
|
||||||
cfg.EraScale = t.EraScale
|
cfg.EraScale = t.EraScale
|
||||||
|
cfg.MoodScale = t.MoodScale
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,6 +324,7 @@ func (s *Service) persistTaste(
|
|||||||
EngagementFull: t.EngagementFull,
|
EngagementFull: t.EngagementFull,
|
||||||
EnrichedTagScale: t.EnrichedTagScale,
|
EnrichedTagScale: t.EnrichedTagScale,
|
||||||
EraScale: t.EraScale,
|
EraScale: t.EraScale,
|
||||||
|
MoodScale: t.MoodScale,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("update taste tuning: %w", err)
|
return fmt.Errorf("update taste tuning: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestReset_RestoresShippedAndAudits(t *testing.T) {
|
||||||
pool := newPool(t)
|
pool := newPool(t)
|
||||||
s := newService(t, pool)
|
s := newService(t, pool)
|
||||||
|
|||||||
+61
-15
@@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/mood"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config holds the operator-tunable knobs. DefaultConfig supplies tuned
|
// Config holds the operator-tunable knobs. DefaultConfig supplies tuned
|
||||||
@@ -54,6 +55,13 @@ type Config struct {
|
|||||||
// lab (mirrors EnrichedTagScale, #1520).
|
// lab (mirrors EnrichedTagScale, #1520).
|
||||||
EraScale float64
|
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
|
// ArtistFloor / TagFloor clamp how negative a single entity's weight may
|
||||||
// go. Aggregation already protects an artist the user likes (one skip
|
// go. Aggregation already protects an artist the user likes (one skip
|
||||||
// nets out against many good plays); the floor additionally bounds the
|
// nets out against many good plays); the floor additionally bounds the
|
||||||
@@ -75,6 +83,8 @@ type Config struct {
|
|||||||
MaxNegTags int
|
MaxNegTags int
|
||||||
MaxEras int
|
MaxEras int
|
||||||
MaxNegEras int
|
MaxNegEras int
|
||||||
|
MaxMoods int
|
||||||
|
MaxNegMoods int
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultConfig returns the tuned starting configuration.
|
// DefaultConfig returns the tuned starting configuration.
|
||||||
@@ -88,6 +98,7 @@ func DefaultConfig() Config {
|
|||||||
TagLikeBonus: 0.5,
|
TagLikeBonus: 0.5,
|
||||||
EnrichedTagScale: 0.5,
|
EnrichedTagScale: 0.5,
|
||||||
EraScale: 0.5,
|
EraScale: 0.5,
|
||||||
|
MoodScale: 0.5,
|
||||||
ArtistFloor: -3.0,
|
ArtistFloor: -3.0,
|
||||||
TagFloor: -3.0,
|
TagFloor: -3.0,
|
||||||
WeightEpsilon: 0.05,
|
WeightEpsilon: 0.05,
|
||||||
@@ -97,6 +108,8 @@ func DefaultConfig() Config {
|
|||||||
MaxNegTags: 60,
|
MaxNegTags: 60,
|
||||||
MaxEras: 30,
|
MaxEras: 30,
|
||||||
MaxNegEras: 15,
|
MaxNegEras: 15,
|
||||||
|
MaxMoods: 20,
|
||||||
|
MaxNegMoods: 10,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,40 +129,42 @@ func BuildTasteProfile(
|
|||||||
) error {
|
) error {
|
||||||
q := dbq.New(pool)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
applyFloor(artistW, cfg.ArtistFloor)
|
applyFloor(artistW, cfg.ArtistFloor)
|
||||||
applyFloor(tagW, cfg.TagFloor)
|
applyFloor(tagW, cfg.TagFloor)
|
||||||
// Era shares the tag floor (both are text-keyed facets; a decade that's
|
// Era + mood share the tag floor (all text-keyed facets; a decade or mood
|
||||||
// been heavily skipped shouldn't dominate as an extreme negative either).
|
// that's been heavily skipped shouldn't dominate as an extreme negative).
|
||||||
applyFloor(eraW, cfg.TagFloor)
|
applyFloor(eraW, cfg.TagFloor)
|
||||||
|
applyFloor(moodW, cfg.TagFloor)
|
||||||
|
|
||||||
strLess := func(a, b string) bool { return a < b }
|
strLess := func(a, b string) bool { return a < b }
|
||||||
artists := rankWeighted(artistW, cfg.WeightEpsilon, cfg.MaxArtists, cfg.MaxNegArtists, uuidLess)
|
artists := rankWeighted(artistW, cfg.WeightEpsilon, cfg.MaxArtists, cfg.MaxNegArtists, uuidLess)
|
||||||
tags := rankWeighted(tagW, cfg.WeightEpsilon, cfg.MaxTags, cfg.MaxNegTags, strLess)
|
tags := rankWeighted(tagW, cfg.WeightEpsilon, cfg.MaxTags, cfg.MaxNegTags, strLess)
|
||||||
eras := rankWeighted(eraW, cfg.WeightEpsilon, cfg.MaxEras, cfg.MaxNegEras, 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
|
return err
|
||||||
}
|
}
|
||||||
logger.Debug("taste: profile rebuilt", "user_id", uuidString(userID),
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// accumulate sums decayed play engagement and like bonuses into artist/tag/era
|
// accumulate sums decayed play engagement and like bonuses into
|
||||||
// weight maps.
|
// artist/tag/era/mood weight maps.
|
||||||
func accumulate(
|
func accumulate(
|
||||||
ctx context.Context, q *dbq.Queries, userID pgtype.UUID, cfg Config,
|
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{
|
plays, err := q.ListPlayEngagementInputsForUser(ctx, dbq.ListPlayEngagementInputsForUserParams{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Column2: cfg.WindowDays,
|
Column2: cfg.WindowDays,
|
||||||
})
|
})
|
||||||
if err != nil {
|
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
|
// Enriched folksonomy tags keyed by track (#1490) — folded into the tag
|
||||||
// facet alongside raw ID3 genre so a coarse "Rock" gains the cached
|
// facet alongside raw ID3 genre so a coarse "Rock" gains the cached
|
||||||
@@ -160,13 +175,14 @@ func accumulate(
|
|||||||
Column2: int32(cfg.WindowDays),
|
Column2: int32(cfg.WindowDays),
|
||||||
})
|
})
|
||||||
if err != nil {
|
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)
|
playedTags := groupTagsByTrack(playedTagRows)
|
||||||
|
|
||||||
artistW := make(map[pgtype.UUID]float64)
|
artistW := make(map[pgtype.UUID]float64)
|
||||||
tagW := make(map[string]float64)
|
tagW := make(map[string]float64)
|
||||||
eraW := make(map[string]float64)
|
eraW := make(map[string]float64)
|
||||||
|
moodW := make(map[string]float64)
|
||||||
for _, p := range plays {
|
for _, p := range plays {
|
||||||
e := Engagement(p.Completion, cfg.Engagement) * decay(p.AgeDays, cfg.HalfLifeDays)
|
e := Engagement(p.Completion, cfg.Engagement) * decay(p.AgeDays, cfg.HalfLifeDays)
|
||||||
artistW[p.ArtistID] += e
|
artistW[p.ArtistID] += e
|
||||||
@@ -175,15 +191,16 @@ func accumulate(
|
|||||||
}
|
}
|
||||||
foldEnrichedTags(tagW, playedTags[p.TrackID], e, cfg.EnrichedTagScale)
|
foldEnrichedTags(tagW, playedTags[p.TrackID], e, cfg.EnrichedTagScale)
|
||||||
foldEra(eraW, p.ReleaseDate, e, cfg.EraScale)
|
foldEra(eraW, p.ReleaseDate, e, cfg.EraScale)
|
||||||
|
foldMoods(moodW, playedTags[p.TrackID], e, cfg.MoodScale)
|
||||||
}
|
}
|
||||||
|
|
||||||
likedTracks, err := q.ListLikedTrackTasteInputsForUser(ctx, userID)
|
likedTracks, err := q.ListLikedTrackTasteInputsForUser(ctx, userID)
|
||||||
if err != nil {
|
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)
|
likedTagRows, err := q.ListLikedTrackTagsForUser(ctx, userID)
|
||||||
if err != nil {
|
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)
|
likedTags := groupTagsByTrack(likedTagRows)
|
||||||
for _, lt := range likedTracks {
|
for _, lt := range likedTracks {
|
||||||
@@ -193,16 +210,17 @@ func accumulate(
|
|||||||
}
|
}
|
||||||
foldEnrichedTags(tagW, likedTags[lt.TrackID], cfg.TagLikeBonus, cfg.EnrichedTagScale)
|
foldEnrichedTags(tagW, likedTags[lt.TrackID], cfg.TagLikeBonus, cfg.EnrichedTagScale)
|
||||||
foldEra(eraW, lt.ReleaseDate, cfg.TagLikeBonus, cfg.EraScale)
|
foldEra(eraW, lt.ReleaseDate, cfg.TagLikeBonus, cfg.EraScale)
|
||||||
|
foldMoods(moodW, likedTags[lt.TrackID], cfg.TagLikeBonus, cfg.MoodScale)
|
||||||
}
|
}
|
||||||
|
|
||||||
likedArtists, err := q.ListLikedArtistIDsForUser(ctx, userID)
|
likedArtists, err := q.ListLikedArtistIDsForUser(ctx, userID)
|
||||||
if err != nil {
|
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 {
|
for _, aid := range likedArtists {
|
||||||
artistW[aid] += cfg.ArtistLikeBonus
|
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
|
// 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 ""
|
// 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
|
// for an absent/invalid date. Mirrored by the recommendation scorer so a
|
||||||
// candidate's era is derived the same way it was learned.
|
// 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.
|
// persist atomic-replaces the user's profile rows inside one transaction.
|
||||||
func persist(
|
func persist(
|
||||||
ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID,
|
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 {
|
) error {
|
||||||
tx, err := pool.Begin(ctx)
|
tx, err := pool.Begin(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -293,6 +329,16 @@ func persist(
|
|||||||
return fmt.Errorf("taste: insert era: %w", err)
|
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 {
|
if err := tx.Commit(ctx); err != nil {
|
||||||
return fmt.Errorf("taste: commit: %w", err)
|
return fmt.Errorf("taste: commit: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export type TasteTuning = {
|
|||||||
engagement_full: number;
|
engagement_full: number;
|
||||||
enriched_tag_scale: number;
|
enriched_tag_scale: number;
|
||||||
era_scale: number;
|
era_scale: number;
|
||||||
|
mood_scale: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TuningScope = 'radio' | 'daily_mix' | 'taste';
|
export type TuningScope = 'radio' | 'daily_mix' | 'taste';
|
||||||
|
|||||||
@@ -39,7 +39,8 @@
|
|||||||
{ key: 'engagement_neutral', label: 'Neutral point', hint: 'Completion at which a play reads 0.' },
|
{ 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: '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: '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 }[] = [
|
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,
|
engagement_full: 0.9,
|
||||||
enriched_tag_scale: 0.5,
|
enriched_tag_scale: 0.5,
|
||||||
era_scale: 0.5,
|
era_scale: 0.5,
|
||||||
|
mood_scale: 0.5,
|
||||||
...over
|
...over
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user