feat(recommendation): extend Score with ContextualMatchScore + ContextWeight

This commit is contained in:
2026-04-27 20:31:54 -04:00
parent 347884bc2b
commit 49871ba06d
2 changed files with 45 additions and 5 deletions
+7 -1
View File
@@ -8,12 +8,15 @@ import (
) )
// ScoringInputs are the per-track facts the score function consumes. // 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 { type ScoringInputs struct {
IsGeneralLiked bool IsGeneralLiked bool
LastPlayedAt *time.Time // nil = never played LastPlayedAt *time.Time // nil = never played
PlayCount int // total play_events PlayCount int // total play_events
SkipCount int // play_events with was_skipped=true 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 // ScoringWeights are the operator-tunable knobs. Defaults live in
@@ -24,6 +27,7 @@ type ScoringWeights struct {
RecencyWeight float64 RecencyWeight float64
SkipPenalty float64 SkipPenalty float64
JitterMagnitude float64 JitterMagnitude float64
ContextWeight float64
} }
// Score computes the weighted-shuffle score per spec §6: // Score computes the weighted-shuffle score per spec §6:
@@ -32,6 +36,7 @@ type ScoringWeights struct {
// + (is_general_liked ? LikeBoost : 0) // + (is_general_liked ? LikeBoost : 0)
// + recency_decay * RecencyWeight // + recency_decay * RecencyWeight
// - skip_ratio * SkipPenalty // - skip_ratio * SkipPenalty
// + contextual_match_score * ContextWeight
// + small_random_jitter // + small_random_jitter
// //
// Higher score = more likely to surface. rng is a function returning a // 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 += recencyDecay(in.LastPlayedAt, now) * w.RecencyWeight
s -= skipRatio(in.PlayCount, in.SkipCount) * w.SkipPenalty s -= skipRatio(in.PlayCount, in.SkipCount) * w.SkipPenalty
s += in.ContextualMatchScore * w.ContextWeight
s += (rng()*2 - 1) * w.JitterMagnitude s += (rng()*2 - 1) * w.JitterMagnitude
return s return s
} }
+34
View File
@@ -148,3 +148,37 @@ func TestSkipRatio_Half(t *testing.T) {
t.Errorf("skipRatio(4,2) = %v, want 0.5", got) 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)
}
}