f0c08e7326
Milestone #160 Opt 2b (mood half of the era+mood option). A fourth taste facet alongside artists + genre tags + eras: signed weights over canonical mood buckets (melancholic / energetic / chill / …) derived from a track's enriched folksonomy tags (#1490). - internal/mood: shared vocabulary — Of(tags) maps folksonomy tags to canonical mood buckets (synonyms collapse). Imported by both the taste builder and the scorer so a track's mood is derived identically. - Migration 0047: taste_profile_moods table + taste_tuning.mood_scale (DEFAULT 0.5). - Build side (internal/taste): Config.MoodScale ([0,1] damper, mirrors EraScale); accumulate folds each play/like's mood buckets at base*MoodScale; persist atomic-replaces the mood rows. - Scorer (internal/recommendation): TasteProfile gains a mood term (own tanh scale + additive 0.12 share, so it never weakens the existing signal when a track has no mood tags). Match now takes the candidate's mood buckets; loaded per candidate (ListTrackTagsForTracks → mood.Of) in the primary similarity loader only — the near-whole-library fallback pool passes nil (mood → 0) to avoid a full-library tag scan. - Tuning lab: mood_scale threaded through recsettings + admin API + web card ("Mood weight" row) + Go/web tests. Coverage is partial (grows with tag enrichment; richer once Last.fm is keyed), so mood is a supplement — neutral for tracks with no mood tags. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
154 lines
4.7 KiB
Go
154 lines
4.7 KiB
Go
package recommendation
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
// Taste-match tuning. The taste profile (written by internal/taste) holds
|
|
// signed, unbounded artist/tag/era weights; these scales squash them into a
|
|
// bounded [-1, +1] match via tanh, so one outlier artist can't compress 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."
|
|
//
|
|
// 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, 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_*
|
|
// tables (written daily by internal/taste). Returns an empty profile with no
|
|
// error when the user has none.
|
|
func LoadTasteProfile(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) (TasteProfile, error) {
|
|
arts, err := q.ListTasteProfileArtistsForUser(ctx, dbq.ListTasteProfileArtistsForUserParams{
|
|
UserID: userID, Limit: tasteProfileLimit,
|
|
})
|
|
if err != nil {
|
|
return TasteProfile{}, err
|
|
}
|
|
tags, err := q.ListTasteProfileTagsForUser(ctx, dbq.ListTasteProfileTagsForUserParams{
|
|
UserID: userID, Limit: tasteProfileLimit,
|
|
})
|
|
if err != nil {
|
|
return TasteProfile{}, err
|
|
}
|
|
eras, err := q.ListTasteProfileErasForUser(ctx, dbq.ListTasteProfileErasForUserParams{
|
|
UserID: userID, Limit: tasteProfileLimit,
|
|
})
|
|
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
|
|
}
|
|
for _, t := range tags {
|
|
p.tags[t.Tag] = t.Weight
|
|
}
|
|
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, 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
|
|
if genre != nil {
|
|
tags := splitGenres(*genre)
|
|
if len(tags) > 0 {
|
|
var sum float64
|
|
for _, t := range tags {
|
|
sum += p.tags[t]
|
|
}
|
|
tg = math.Tanh((sum / float64(len(tags))) / tasteTagScale)
|
|
}
|
|
}
|
|
|
|
var er float64
|
|
if decade := decadeOf(releaseDate); decade != "" {
|
|
er = math.Tanh(p.eras[decade] / tasteEraScale)
|
|
}
|
|
|
|
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
|
|
// an absent date. Mirrors the taste builder's helper (kept local to avoid a
|
|
// cross-package dependency) so a candidate's era is derived exactly as learned.
|
|
func decadeOf(d pgtype.Date) string {
|
|
if !d.Valid {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("%ds", (d.Time.Year()/10)*10)
|
|
}
|
|
|
|
// clampUnit constrains x to [-1, 1].
|
|
func clampUnit(x float64) float64 {
|
|
if x < -1 {
|
|
return -1
|
|
}
|
|
if x > 1 {
|
|
return 1
|
|
}
|
|
return x
|
|
}
|