From 49871ba06dc682eb8c94ce31acb0c687a39475f3 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 27 Apr 2026 20:31:54 -0400 Subject: [PATCH] feat(recommendation): extend Score with ContextualMatchScore + ContextWeight --- internal/recommendation/score.go | 16 +++++++++---- internal/recommendation/score_test.go | 34 +++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/internal/recommendation/score.go b/internal/recommendation/score.go index f71e193a..82f4e940 100644 --- a/internal/recommendation/score.go +++ b/internal/recommendation/score.go @@ -8,12 +8,15 @@ import ( ) // ScoringInputs are the per-track facts the score function consumes. -// Sub-plan #3 (contextual scoring) extends this with ContextualMatchScore. +// 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. type ScoringInputs struct { - IsGeneralLiked bool - LastPlayedAt *time.Time // nil = never played - PlayCount int // total play_events - SkipCount int // play_events with was_skipped=true + 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 } // ScoringWeights are the operator-tunable knobs. Defaults live in @@ -24,6 +27,7 @@ type ScoringWeights struct { RecencyWeight float64 SkipPenalty float64 JitterMagnitude float64 + ContextWeight float64 } // Score computes the weighted-shuffle score per spec §6: @@ -32,6 +36,7 @@ type ScoringWeights struct { // + (is_general_liked ? LikeBoost : 0) // + recency_decay * RecencyWeight // - skip_ratio * SkipPenalty +// + contextual_match_score * ContextWeight // + small_random_jitter // // Higher score = more likely to surface. rng is a function returning a @@ -44,6 +49,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 += (rng()*2 - 1) * w.JitterMagnitude return s } diff --git a/internal/recommendation/score_test.go b/internal/recommendation/score_test.go index bbee4f2c..f4521910 100644 --- a/internal/recommendation/score_test.go +++ b/internal/recommendation/score_test.go @@ -148,3 +148,37 @@ func TestSkipRatio_Half(t *testing.T) { t.Errorf("skipRatio(4,2) = %v, want 0.5", got) } } + +func TestScore_ContextualMatch_PerfectMatchAtWeight2(t *testing.T) { + w := defaultWeights() + w.ContextWeight = 2.0 + in := ScoringInputs{ContextualMatchScore: 1.0} + got := Score(in, w, time.Now(), fixedRNG(0.5)) + // base 1.0 + recency 1.0 (never played) + contextual 2.0 = 4.0 + want := 4.0 + if math.Abs(got-want) > 1e-9 { + t.Errorf("score = %v, want %v", got, want) + } +} + +func TestScore_ContextualMatch_HalfMatchAtWeight2(t *testing.T) { + w := defaultWeights() + w.ContextWeight = 2.0 + in := ScoringInputs{ContextualMatchScore: 0.5} + got := Score(in, w, time.Now(), fixedRNG(0.5)) + // base 1.0 + recency 1.0 + contextual 1.0 = 3.0 + want := 3.0 + if math.Abs(got-want) > 1e-9 { + t.Errorf("score = %v, want %v", got, want) + } +} + +func TestScore_ContextualMatch_ZeroNoEffect(t *testing.T) { + wWithCtx := defaultWeights() + wWithCtx.ContextWeight = 2.0 + withCtx := Score(ScoringInputs{ContextualMatchScore: 0}, wWithCtx, time.Now(), fixedRNG(0.5)) + withoutCtx := Score(ScoringInputs{}, defaultWeights(), time.Now(), fixedRNG(0.5)) + if math.Abs(withCtx-withoutCtx) > 1e-9 { + t.Errorf("score-with-zero-ctx = %v, score-without = %v; should be equal", withCtx, withoutCtx) + } +}