Files
minstrel/internal/recommendation/taste.go
T
bvandeusenandClaude Opus 5.5 fcf5b7e5a8 docs: wanted, dbtest, recommendation and playlists comments say what the code does (#6277)
The same rewrite as d191a282, for four packages whose comments carried the
most history: tuning stories, quotes from the operator, single-install
measurements in library_scale.go, and 'used to' accounts of bugs. The
constraint each story protected stays as a present-tense reason (why tier 3
is reduced not removed, why the randomised arms take an order seed, why the
seed artist is eligible in Songs-like). Comments naming system_cron.go and
config.RecommendationConfig, neither of which holds what they claimed, now
name the scheduler and internal/recsettings.

Checked: non-comment tokens identical to HEAD; gofmt clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:19:15 -04:00

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 and mood 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 tuning 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
}