diff --git a/internal/api/radio.go b/internal/api/radio.go index 5896324d..c99a26f2 100644 --- a/internal/api/radio.go +++ b/internal/api/radio.go @@ -108,6 +108,7 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) { JitterMagnitude: h.recCfg.JitterMagnitude, ContextWeight: h.recCfg.ContextWeight, SimilarityWeight: h.recCfg.SimilarityWeight, + TasteWeight: h.recCfg.TasteWeight, } picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1) diff --git a/internal/config/config.go b/internal/config/config.go index e28d4432..f810a988 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -98,6 +98,7 @@ type RecommendationConfig struct { JitterMagnitude float64 `yaml:"jitter_magnitude"` ContextWeight float64 `yaml:"context_weight"` SimilarityWeight float64 `yaml:"similarity_weight"` + TasteWeight float64 `yaml:"taste_weight"` RecentlyPlayedHours int `yaml:"recently_played_hours"` RadioSize int `yaml:"radio_size"` RadioSizeMax int `yaml:"radio_size_max"` @@ -119,13 +120,16 @@ func Default() Config { SkipMaxDurationPlayedMs: 30000, }, Recommendation: RecommendationConfig{ - BaseWeight: 1.0, - LikeBoost: 2.0, - RecencyWeight: 1.0, - SkipPenalty: 1.0, - JitterMagnitude: 0.1, - ContextWeight: 2.0, - SimilarityWeight: 2.0, + BaseWeight: 1.0, + LikeBoost: 2.0, + RecencyWeight: 1.0, + SkipPenalty: 1.0, + JitterMagnitude: 0.1, + ContextWeight: 2.0, + SimilarityWeight: 2.0, + // Radio is seed-directed (the user picked a direction), so taste + // is a lighter nudge here than in the daily mixes (1.5). + TasteWeight: 1.0, RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200, diff --git a/internal/playlists/system.go b/internal/playlists/system.go index 87bf5595..1efdba40 100644 --- a/internal/playlists/system.go +++ b/internal/playlists/system.go @@ -148,6 +148,11 @@ var systemMixWeights = recommendation.ScoringWeights{ JitterMagnitude: 0.1, ContextWeight: 0.5, SimilarityWeight: 1.5, + // Taste profile (#796 phase 2): the daily mixes are the primary + // taste-driven surface, so they lean on it. TasteMatchScore is in + // [-1,+1], so 1.5 makes a strong taste fit comparable to a like boost + // while passive avoidance (negative) gently demotes. + TasteWeight: 1.5, } // forYouHeadN is the number of top-scored tracks that anchor the For-You diff --git a/internal/recommendation/candidates.go b/internal/recommendation/candidates.go index 6c30cb21..ad0d6d39 100644 --- a/internal/recommendation/candidates.go +++ b/internal/recommendation/candidates.go @@ -36,6 +36,11 @@ func LoadCandidates( return nil, err } + profile, err := LoadTasteProfile(ctx, q, userID) + if err != nil { + return nil, err + } + out := make([]Candidate, 0, len(rows)) for _, r := range rows { var lpt *time.Time @@ -52,6 +57,7 @@ func LoadCandidates( PlayCount: int(r.PlayCount), SkipCount: int(r.SkipCount), ContextualMatchScore: ctxScore, + TasteMatchScore: profile.Match(r.Track.ArtistID, r.Track.Genre), }, }) } @@ -117,6 +123,11 @@ func LoadCandidatesFromSimilarity( return nil, err } + profile, err := LoadTasteProfile(ctx, q, userID) + if err != nil { + return nil, err + } + out := make([]Candidate, 0, len(rows)) for _, r := range rows { var lpt *time.Time @@ -141,6 +152,7 @@ func LoadCandidatesFromSimilarity( SkipCount: int(r.SkipCount), ContextualMatchScore: ctxScore, SimilarityScore: simScore, + TasteMatchScore: profile.Match(r.Track.ArtistID, r.Track.Genre), }, }) } diff --git a/internal/recommendation/score.go b/internal/recommendation/score.go index 3c6f061e..c3b9a9ef 100644 --- a/internal/recommendation/score.go +++ b/internal/recommendation/score.go @@ -19,6 +19,11 @@ type ScoringInputs struct { SkipCount int // play_events with was_skipped=true ContextualMatchScore float64 // [0, 1]; 0 when no signal SimilarityScore float64 // [0, 1]; 0 when no signal (random fill) + // TasteMatchScore is the candidate's fit to the user's learned taste + // profile (#796 phase 2), in [-1, +1]: positive draws a track toward the + // user's taste, negative reflects passive avoidance, 0 when there's no + // profile signal (cold start / artist+tags absent from the profile). + TasteMatchScore float64 } // ScoringWeights are the operator-tunable knobs. Defaults live in @@ -31,6 +36,7 @@ type ScoringWeights struct { JitterMagnitude float64 ContextWeight float64 SimilarityWeight float64 + TasteWeight float64 } // Score computes the weighted-shuffle score per spec §6: @@ -41,6 +47,7 @@ type ScoringWeights struct { // - skip_ratio * SkipPenalty // + contextual_match_score * ContextWeight // + similarity_score * SimilarityWeight +// + taste_match_score * TasteWeight // + small_random_jitter // // Higher score = more likely to surface. rng is a function returning a @@ -55,6 +62,7 @@ func Score(in ScoringInputs, w ScoringWeights, now time.Time, rng func() float64 s -= skipRatio(in.PlayCount, in.SkipCount) * w.SkipPenalty s += in.ContextualMatchScore * w.ContextWeight s += in.SimilarityScore * w.SimilarityWeight + s += in.TasteMatchScore * w.TasteWeight s += (rng()*2 - 1) * w.JitterMagnitude return s } diff --git a/internal/recommendation/taste.go b/internal/recommendation/taste.go new file mode 100644 index 00000000..0953b01c --- /dev/null +++ b/internal/recommendation/taste.go @@ -0,0 +1,91 @@ +package recommendation + +import ( + "context" + "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 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." +const ( + tasteArtistScale = 4.0 + tasteTagScale = 3.0 + tasteArtistShare = 0.7 + tasteTagShare = 0.3 + 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 and genre tags. The zero value (and any unknown +// artist/tag) contributes 0, so cold-start users get no taste effect. +type TasteProfile struct { + artists map[pgtype.UUID]float64 + tags 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 + } + p := TasteProfile{ + artists: make(map[pgtype.UUID]float64, len(arts)), + tags: make(map[string]float64, len(tags)), + } + for _, a := range arts { + p.artists[a.ArtistID] = a.Weight + } + for _, t := range tags { + p.tags[t.Tag] = t.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 and the average of its genre tags' weights, each +// tanh-squashed. Absent artist/tags contribute 0. +func (p TasteProfile) Match(artistID pgtype.UUID, genre *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) + } + } + return clampUnit(tasteArtistShare*a + tasteTagShare*tg) +} + +// clampUnit constrains x to [-1, 1]. +func clampUnit(x float64) float64 { + if x < -1 { + return -1 + } + if x > 1 { + return 1 + } + return x +} diff --git a/internal/recommendation/taste_test.go b/internal/recommendation/taste_test.go new file mode 100644 index 00000000..408e894a --- /dev/null +++ b/internal/recommendation/taste_test.go @@ -0,0 +1,109 @@ +package recommendation + +import ( + "context" + "math" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +func uuidN(n byte) pgtype.UUID { + return pgtype.UUID{Bytes: [16]byte{15: n}, Valid: true} +} + +func strPtr(s string) *string { return &s } + +func TestTasteProfile_Match(t *testing.T) { + loved := uuidN(1) + disliked := uuidN(2) + unknown := uuidN(3) + p := TasteProfile{ + artists: map[pgtype.UUID]float64{loved: 8.0, disliked: -8.0}, + tags: map[string]float64{"Jazz": 6.0, "Noise": -6.0}, + } + + if m := p.Match(loved, strPtr("Jazz")); m <= 0.5 { + t.Errorf("loved artist + loved tag = %.3f, want strongly positive", m) + } + if m := p.Match(disliked, strPtr("Noise")); m >= -0.5 { + t.Errorf("disliked artist + disliked tag = %.3f, want strongly negative", m) + } + if m := p.Match(unknown, 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")); 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")) + if m < -1 || m > 1 { + t.Errorf("Match out of [-1,1]: %.3f", m) + } + } +} + +func TestTasteProfile_EmptyIsNeutral(t *testing.T) { + var p TasteProfile // zero value: nil maps + if m := p.Match(uuidN(1), strPtr("Jazz")); m != 0 { + t.Errorf("empty profile Match = %.3f, want 0 (cold start neutral)", m) + } +} + +func TestScore_TasteTermAddsAndSubtracts(t *testing.T) { + now := time.Now() + zeroJitter := func() float64 { return 0.5 } // (0.5*2-1)=0 with any magnitude + w := ScoringWeights{TasteWeight: 2.0} // all other weights 0 + + pos := Score(ScoringInputs{TasteMatchScore: 1.0}, w, now, zeroJitter) + if !almostEq(pos, 2.0) { + t.Errorf("positive taste: Score = %.3f, want 2.0", pos) + } + neg := Score(ScoringInputs{TasteMatchScore: -1.0}, w, now, zeroJitter) + if !almostEq(neg, -2.0) { + t.Errorf("negative taste: Score = %.3f, want -2.0 (demotes)", neg) + } + off := Score(ScoringInputs{TasteMatchScore: 1.0}, ScoringWeights{}, now, zeroJitter) + if !almostEq(off, 0.0) { + t.Errorf("TasteWeight 0: Score = %.3f, want 0 (no effect)", off) + } +} + +func almostEq(a, b float64) bool { return math.Abs(a-b) < 1e-9 } + +// TestLoadTasteProfile_RoundTrip seeds taste_profile rows and verifies the +// reader hydrates them into a profile that scores a matching track positively. +func TestLoadTasteProfile_RoundTrip(t *testing.T) { + pool := newPool(t) + ctx := context.Background() + u := seedUser(t, pool, "taste-rt") + art := seedArtist(t, pool, "Loved Artist", "") + + if _, err := pool.Exec(ctx, + `INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, $3)`, + u.ID, art.ID, 8.0); err != nil { + t.Fatalf("seed taste artist: %v", err) + } + if _, err := pool.Exec(ctx, + `INSERT INTO taste_profile_tags (user_id, tag, weight) VALUES ($1, $2, $3)`, + u.ID, "Jazz", 6.0); err != nil { + t.Fatalf("seed taste tag: %v", err) + } + + p, err := LoadTasteProfile(ctx, dbq.New(pool), u.ID) + if err != nil { + t.Fatalf("load: %v", err) + } + if m := p.Match(art.ID, strPtr("Jazz")); m <= 0.5 { + t.Errorf("round-trip Match = %.3f, want strongly positive", m) + } + if m := p.Match(uuidN(9), nil); m != 0 { + t.Errorf("absent artist Match = %.3f, want 0", m) + } +}