diff --git a/internal/config/config.go b/internal/config/config.go index 059aa0a8..03a34856 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -73,6 +73,7 @@ type RecommendationConfig struct { SkipPenalty float64 `yaml:"skip_penalty"` JitterMagnitude float64 `yaml:"jitter_magnitude"` ContextWeight float64 `yaml:"context_weight"` + SimilarityWeight float64 `yaml:"similarity_weight"` RecentlyPlayedHours int `yaml:"recently_played_hours"` RadioSize int `yaml:"radio_size"` RadioSizeMax int `yaml:"radio_size_max"` @@ -95,6 +96,7 @@ func Default() Config { SkipPenalty: 1.0, JitterMagnitude: 0.1, ContextWeight: 2.0, + SimilarityWeight: 2.0, RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200, diff --git a/internal/recommendation/score.go b/internal/recommendation/score.go index 82f4e940..3c6f061e 100644 --- a/internal/recommendation/score.go +++ b/internal/recommendation/score.go @@ -11,23 +11,26 @@ import ( // 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 + BaseWeight float64 + LikeBoost float64 + RecencyWeight float64 + SkipPenalty float64 + JitterMagnitude float64 + ContextWeight float64 + SimilarityWeight float64 } // Score computes the weighted-shuffle score per spec §6: @@ -37,6 +40,7 @@ type ScoringWeights struct { // + 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 @@ -50,6 +54,7 @@ func Score(in ScoringInputs, w ScoringWeights, now time.Time, rng func() float64 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 } diff --git a/internal/recommendation/score_test.go b/internal/recommendation/score_test.go index f4521910..908ab3e5 100644 --- a/internal/recommendation/score_test.go +++ b/internal/recommendation/score_test.go @@ -182,3 +182,37 @@ func TestScore_ContextualMatch_ZeroNoEffect(t *testing.T) { t.Errorf("score-with-zero-ctx = %v, score-without = %v; should be equal", withCtx, withoutCtx) } } + +func TestScore_SimilarityScore_PerfectMatchAtWeight2(t *testing.T) { + w := defaultWeights() + w.SimilarityWeight = 2.0 + in := ScoringInputs{SimilarityScore: 1.0} + got := Score(in, w, time.Now(), fixedRNG(0.5)) + // base 1.0 + recency 1.0 (never played) + similarity 2.0 = 4.0 + want := 4.0 + if math.Abs(got-want) > 1e-9 { + t.Errorf("score = %v, want %v", got, want) + } +} + +func TestScore_SimilarityScore_HalfMatchAtWeight2(t *testing.T) { + w := defaultWeights() + w.SimilarityWeight = 2.0 + in := ScoringInputs{SimilarityScore: 0.5} + got := Score(in, w, time.Now(), fixedRNG(0.5)) + // base 1.0 + recency 1.0 + similarity 1.0 = 3.0 + want := 3.0 + if math.Abs(got-want) > 1e-9 { + t.Errorf("score = %v, want %v", got, want) + } +} + +func TestScore_SimilarityScore_ZeroNoEffect(t *testing.T) { + wWithSim := defaultWeights() + wWithSim.SimilarityWeight = 2.0 + withSim := Score(ScoringInputs{SimilarityScore: 0}, wWithSim, time.Now(), fixedRNG(0.5)) + withoutSim := Score(ScoringInputs{}, defaultWeights(), time.Now(), fixedRNG(0.5)) + if math.Abs(withSim-withoutSim) > 1e-9 { + t.Errorf("score-with-zero-sim = %v, score-without = %v; should be equal", withSim, withoutSim) + } +}