diff --git a/internal/recommendation/score.go b/internal/recommendation/score.go new file mode 100644 index 00000000..f71e193a --- /dev/null +++ b/internal/recommendation/score.go @@ -0,0 +1,79 @@ +// 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. +// Sub-plan #3 (contextual scoring) extends this with ContextualMatchScore. +type ScoringInputs struct { + IsGeneralLiked bool + LastPlayedAt *time.Time // nil = never played + PlayCount int // total play_events + SkipCount int // play_events with was_skipped=true +} + +// 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 +} + +// Score computes the weighted-shuffle score per spec §6: +// +// score = base +// + (is_general_liked ? LikeBoost : 0) +// + recency_decay * RecencyWeight +// - skip_ratio * SkipPenalty +// + 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 += (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) +} diff --git a/internal/recommendation/score_test.go b/internal/recommendation/score_test.go new file mode 100644 index 00000000..bbee4f2c --- /dev/null +++ b/internal/recommendation/score_test.go @@ -0,0 +1,150 @@ +package recommendation + +import ( + "math" + "math/rand" + "testing" + "time" +) + +func defaultWeights() ScoringWeights { + return ScoringWeights{ + BaseWeight: 1.0, + LikeBoost: 2.0, + RecencyWeight: 1.0, + SkipPenalty: 1.0, + JitterMagnitude: 0.1, + } +} + +func fixedRNG(v float64) func() float64 { + return func() float64 { return v } +} + +func TestScore_BaseCase_NeverPlayed_NoJitter(t *testing.T) { + in := ScoringInputs{} + now := time.Now().UTC() + got := Score(in, defaultWeights(), now, fixedRNG(0.5)) + // rng=0.5 -> jitter contribution = (0.5*2 - 1) * 0.1 = 0 + // expected = 1.0 + 0 (not liked) + 1.0 (never played) - 0 + 0 = 2.0 + want := 2.0 + if math.Abs(got-want) > 1e-9 { + t.Errorf("score = %v, want %v", got, want) + } +} + +func TestScore_LikeBoost(t *testing.T) { + notLiked := Score(ScoringInputs{}, defaultWeights(), time.Now(), fixedRNG(0.5)) + liked := Score(ScoringInputs{IsGeneralLiked: true}, defaultWeights(), time.Now(), fixedRNG(0.5)) + if math.Abs(liked-notLiked-2.0) > 1e-9 { + t.Errorf("delta = %v, want 2.0 (LikeBoost)", liked-notLiked) + } +} + +func TestScore_RecencyRamp_15Days(t *testing.T) { + now := time.Now().UTC() + played := now.Add(-15 * 24 * time.Hour) + got := Score(ScoringInputs{LastPlayedAt: &played}, defaultWeights(), now, fixedRNG(0.5)) + // recency = 0.5 -> contributes 0.5 * 1.0 = 0.5; expected = 1.0 + 0 + 0.5 - 0 + 0 = 1.5 + want := 1.5 + if math.Abs(got-want) > 1e-9 { + t.Errorf("score = %v, want %v", got, want) + } +} + +func TestScore_RecencyRamp_60DaysCapsAt1(t *testing.T) { + now := time.Now().UTC() + played := now.Add(-60 * 24 * time.Hour) + got := Score(ScoringInputs{LastPlayedAt: &played}, defaultWeights(), now, fixedRNG(0.5)) + want := 2.0 // base + capped recency (1.0) + if math.Abs(got-want) > 1e-9 { + t.Errorf("score = %v, want %v", got, want) + } +} + +func TestScore_SkipRatio(t *testing.T) { + // 4 plays, 2 skips -> ratio 0.5 -> -0.5 * SkipPenalty = -0.5 + in := ScoringInputs{PlayCount: 4, SkipCount: 2} + got := Score(in, defaultWeights(), time.Now(), fixedRNG(0.5)) + // recency = 1.0 (never played by virtue of LastPlayedAt being nil even though PlayCount > 0; + // for this test we rely on the recencyDecay treating nil as max). Adjust: the tests above + // explicitly use nil for LastPlayedAt unless set. So: 1.0 + 1.0 - 0.5 = 1.5. + want := 1.5 + if math.Abs(got-want) > 1e-9 { + t.Errorf("score = %v, want %v", got, want) + } +} + +func TestScore_ColdStartSkip_ZeroPlaysZeroSkips(t *testing.T) { + in := ScoringInputs{PlayCount: 0, SkipCount: 0} + got := Score(in, defaultWeights(), time.Now(), fixedRNG(0.5)) + want := 2.0 // base + recency, no skip penalty + if math.Abs(got-want) > 1e-9 { + t.Errorf("score = %v, want %v", got, want) + } +} + +func TestScore_JitterBounds(t *testing.T) { + r := rand.New(rand.NewSource(42)) + w := defaultWeights() + now := time.Now().UTC() + mid := Score(ScoringInputs{}, w, now, fixedRNG(0.5)) // jitter contribution = 0 + for i := 0; i < 1000; i++ { + got := Score(ScoringInputs{}, w, now, r.Float64) + if got < mid-w.JitterMagnitude-1e-9 || got > mid+w.JitterMagnitude+1e-9 { + t.Fatalf("score %v outside jitter band [%v, %v]", + got, mid-w.JitterMagnitude, mid+w.JitterMagnitude) + } + } +} + +func TestScore_Determinism(t *testing.T) { + in := ScoringInputs{IsGeneralLiked: true, PlayCount: 10, SkipCount: 3} + w := defaultWeights() + now := time.Now().UTC() + a := Score(in, w, now, fixedRNG(0.7)) + b := Score(in, w, now, fixedRNG(0.7)) + if a != b { + t.Errorf("non-deterministic with fixed RNG: %v vs %v", a, b) + } +} + +func TestRecencyDecay_NilIsOne(t *testing.T) { + if got := recencyDecay(nil, time.Now()); got != 1.0 { + t.Errorf("recencyDecay(nil) = %v, want 1.0", got) + } +} + +func TestRecencyDecay_Clamping(t *testing.T) { + now := time.Now().UTC() + cases := []struct { + ageDays float64 + want float64 + }{ + {0, 0.0}, + {15, 0.5}, + {30, 1.0}, + {45, 1.0}, + } + for _, c := range cases { + t.Run("", func(t *testing.T) { + past := now.Add(-time.Duration(c.ageDays * 24 * float64(time.Hour))) + got := recencyDecay(&past, now) + if math.Abs(got-c.want) > 1e-9 { + t.Errorf("ageDays=%v decay=%v want %v", c.ageDays, got, c.want) + } + }) + } +} + +func TestSkipRatio_ZeroPlays_IsZero(t *testing.T) { + if got := skipRatio(0, 0); got != 0.0 { + t.Errorf("skipRatio(0,0) = %v, want 0.0", got) + } +} + +func TestSkipRatio_Half(t *testing.T) { + if got := skipRatio(4, 2); got != 0.5 { + t.Errorf("skipRatio(4,2) = %v, want 0.5", got) + } +}