65dd132b3d
Milestone #160 Opt 3 (temporal half). A new additive scoring term that boosts a candidate when its artist's play history concentrates in the CURRENT daypart × weekday-type cell, in the user's local timezone. - Migration 0046: recommendation_weight_profiles.context_time_weight (per-profile scoring weight, DEFAULT 1.0). - Query ListArtistContextPlayCountsForUser: per-artist completed-play counts split by the current cell (daypart night[22,5)/morning[5,12)/ afternoon[12,17)/evening[17,22) × weekday-vs-weekend) via started_at AT TIME ZONE users.timezone; 365-day window, skips excluded. - internal/recommendation/context.go: LoadContextAffinity computes each artist's shrunk cell-share minus the user's baseline share, clamped to [-1,1]; sparse artists shrink toward baseline (pseudo-count 5), unknown artists → 0 (cold-start neutral). - Score() gains context_affinity_score · ContextTimeWeight; both candidate loaders set it per candidate. - Tuning lab: ContextTimeWeight threaded through recsettings + admin API + web card ("Time-of-day weight" row) + Go/web tests. Shipped 1.0 both profiles (uniform start, re-bakeable). Device-class axis deferred to #1551 (needs a client_id → device-class mapping that doesn't exist yet). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
108 lines
3.9 KiB
Go
108 lines
3.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)
|
||
// 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
|
||
// ContextAffinityScore is the candidate artist's time-of-day/weekday
|
||
// affinity for the CURRENT context (#1531), 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 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
|
||
ContextTimeWeight 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
|
||
// + 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)
|
||
}
|