// 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) }