91 lines
2.9 KiB
Go
91 lines
2.9 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)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
// + 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 += (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)
|
|
}
|