feat(taste): phase 2a — apply the taste profile via a TasteMatch scoring term (#796)
test-go / test (push) Successful in 39s
test-go / integration (push) Successful in 4m34s

The profile built in phase 1 now changes what gets surfaced. Adds a TasteMatch
term to the weighted-shuffle score so candidates are re-ranked by their fit to
the user's learned taste (positive draws toward it; negative reflects passive
avoidance; 0 at cold start).

- recommendation/score.go: ScoringInputs.TasteMatchScore ([-1,+1]) +
  ScoringWeights.TasteWeight + the term in Score.
- recommendation/taste.go: LoadTasteProfile reads the taste_profile_* tables;
  TasteProfile.Match blends the candidate's artist weight (0.7) and avg genre-tag
  weight (0.3), each tanh-squashed by a fixed scale so one outlier artist can't
  compress the rest. Unknown artist/tags and empty profiles → 0 (neutral).
- candidates.go: both candidate loaders set TasteMatchScore per candidate, so
  every Score caller (system playlists incl. You-might-like, radio) becomes
  taste-aware automatically.
- weights: systemMixWeights.TasteWeight = 1.5 (daily mixes are the primary
  taste surface); config.RecommendationConfig gains taste_weight (default 1.0,
  lighter — radio is seed-directed) wired into the radio handler.
- tests: pure (Match curve incl. saturation/clamp/empty-neutral, Score term
  add+subtract) + DB round-trip (seed taste rows → Match positive). All green
  vs real Postgres; existing playlist/radio tests unaffected (empty profile →
  zero taste effect).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 21:29:42 -04:00
parent 13b3fca949
commit aff346c731
7 changed files with 237 additions and 7 deletions
+1
View File
@@ -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)
+11 -7
View File
@@ -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,
+5
View File
@@ -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
+12
View File
@@ -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),
},
})
}
+8
View File
@@ -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
}
+91
View File
@@ -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
}
+109
View File
@@ -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)
}
}