feat(taste): phase 2a — apply the taste profile via a TasteMatch scoring term (#796)
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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user