aff346c731
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>
99 lines
3.4 KiB
Go
99 lines
3.4 KiB
Go
// Package recommendation implements the weighted-shuffle scoring engine
|
|
// from spec §6. The Score function is pure and takes an injectable RNG so
|
|
// tests can pin jitter to deterministic values.
|
|
package recommendation
|
|
|
|
import (
|
|
"time"
|
|
)
|
|
|
|
// ScoringInputs are the per-track facts the score function consumes.
|
|
// ContextualMatchScore is in [0, 1] — max similarity between the user's
|
|
// current session vector and any non-seed contextual_like row for this
|
|
// track. Set by LoadCandidates after a bulk fetch.
|
|
// SimilarityScore is in [0, 1]; 0 when no signal (random fill).
|
|
type ScoringInputs struct {
|
|
IsGeneralLiked bool
|
|
LastPlayedAt *time.Time // nil = never played
|
|
PlayCount int // total play_events
|
|
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
|
|
// config.RecommendationConfig and are propagated here per request.
|
|
type ScoringWeights struct {
|
|
BaseWeight float64
|
|
LikeBoost float64
|
|
RecencyWeight float64
|
|
SkipPenalty float64
|
|
JitterMagnitude float64
|
|
ContextWeight float64
|
|
SimilarityWeight float64
|
|
TasteWeight float64
|
|
}
|
|
|
|
// Score computes the weighted-shuffle score per spec §6:
|
|
//
|
|
// score = base
|
|
// + (is_general_liked ? LikeBoost : 0)
|
|
// + recency_decay * RecencyWeight
|
|
// - 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
|
|
// uniform sample in [0,1) — pass math/rand.Float64 in production, a fixed
|
|
// value in tests.
|
|
func Score(in ScoringInputs, w ScoringWeights, now time.Time, rng func() float64) float64 {
|
|
s := w.BaseWeight
|
|
if in.IsGeneralLiked {
|
|
s += w.LikeBoost
|
|
}
|
|
s += recencyDecay(in.LastPlayedAt, now) * w.RecencyWeight
|
|
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
|
|
}
|
|
|
|
// recencyDecay returns a value in [0, 1]:
|
|
// - never played → 1.0 (cold-start tracks compete favorably with stale ones).
|
|
// - age < 30 days → linear ramp age_days / 30.
|
|
// - age ≥ 30 days → 1.0 (capped).
|
|
//
|
|
// Negative ages (clock skew) clamp to 0 to avoid math weirdness.
|
|
func recencyDecay(lastPlayed *time.Time, now time.Time) float64 {
|
|
if lastPlayed == nil {
|
|
return 1.0
|
|
}
|
|
age := now.Sub(*lastPlayed)
|
|
days := age.Hours() / 24
|
|
if days < 0 {
|
|
return 0.0
|
|
}
|
|
if days >= 30 {
|
|
return 1.0
|
|
}
|
|
return days / 30.0
|
|
}
|
|
|
|
// skipRatio returns skips/plays in [0, 1]; never-played tracks return 0
|
|
// rather than dividing by zero, so they aren't penalized.
|
|
func skipRatio(plays, skips int) float64 {
|
|
if plays == 0 {
|
|
return 0.0
|
|
}
|
|
return float64(skips) / float64(plays)
|
|
}
|