The same rewrite as d191a282, for four packages whose comments carried the
most history: tuning stories, quotes from the operator, single-install
measurements in library_scale.go, and 'used to' accounts of bugs. The
constraint each story protected stays as a present-tense reason (why tier 3
is reduced not removed, why the randomised arms take an order seed, why the
seed artist is eligible in Songs-like). Comments naming system_cron.go and
config.RecommendationConfig, neither of which holds what they claimed, now
name the scheduler and internal/recsettings.
Checked: non-comment tokens identical to HEAD; gofmt clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
108 lines
3.8 KiB
Go
108 lines
3.8 KiB
Go
// Package recommendation implements the weighted-shuffle scoring engine.
|
||
// 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, 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
|
||
// ContextAffinityScore is the candidate artist's time-of-day/weekday
|
||
// affinity for the CURRENT context, in [-1, +1]: positive when the
|
||
// artist's plays concentrate in the current daypart × weekday-type cell
|
||
// more than the user's baseline, negative when under-represented, 0 when
|
||
// there's no history (cold-start neutral).
|
||
ContextAffinityScore float64
|
||
}
|
||
|
||
// ScoringWeights are the tunable knobs. The shipped values and any tuned ones
|
||
// live in internal/recsettings, which hands them in per request.
|
||
type ScoringWeights struct {
|
||
BaseWeight float64
|
||
LikeBoost float64
|
||
RecencyWeight float64
|
||
SkipPenalty float64
|
||
JitterMagnitude float64
|
||
ContextWeight float64
|
||
SimilarityWeight float64
|
||
TasteWeight float64
|
||
ContextTimeWeight float64
|
||
}
|
||
|
||
// Score computes the weighted-shuffle score:
|
||
//
|
||
// score = base
|
||
// + (is_general_liked ? LikeBoost : 0)
|
||
// + recency_decay * RecencyWeight
|
||
// - skip_ratio * SkipPenalty
|
||
// + contextual_match_score * ContextWeight
|
||
// + similarity_score * SimilarityWeight
|
||
// + taste_match_score * TasteWeight
|
||
// + context_affinity_score * ContextTimeWeight
|
||
// + 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 += in.ContextAffinityScore * w.ContextTimeWeight
|
||
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)
|
||
}
|