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 }