feat(recommendation): add ContextualMatchScore (max over non-seed likes)

This commit is contained in:
2026-04-27 20:20:53 -04:00
parent a7211aacff
commit 347884bc2b
2 changed files with 88 additions and 0 deletions
+25
View File
@@ -74,3 +74,28 @@ func setJaccardSlice(a, b []string) float64 {
}
return float64(intersect) / float64(union)
}
// ContextualMatchScore returns the maximum Similarity between the current
// session vector and any non-seed entry in likes. Returns 0 when:
// - current.Seed is true (no meaningful current context)
// - likes is empty after filtering out Seed=true entries
//
// The "max" semantics means a single strong contextual match dominates
// over many weak ones — we want to surface the track because it was liked
// in *some* matching context, not because it was vaguely-liked in many.
func ContextualMatchScore(current SessionVector, likes []SessionVector, w SimilarityWeights) float64 {
if current.Seed {
return 0.0
}
best := 0.0
for _, l := range likes {
if l.Seed {
continue
}
s := Similarity(current, l, w)
if s > best {
best = s
}
}
return best
}
@@ -92,3 +92,66 @@ func TestSimilarity_BagOfCountsCollapsesToSet(t *testing.T) {
t.Errorf("set-collapse = %v, want 1.0", got)
}
}
func TestContextualMatchScore_NoLikes_Returns0(t *testing.T) {
current := SessionVector{Artists: []string{"a"}, Tags: map[string]int{"rock": 1}}
got := ContextualMatchScore(current, nil, DefaultSimilarityWeights)
if !approxEq(got, 0.0) {
t.Errorf("no likes = %v, want 0.0", got)
}
got = ContextualMatchScore(current, []SessionVector{}, DefaultSimilarityWeights)
if !approxEq(got, 0.0) {
t.Errorf("empty likes = %v, want 0.0", got)
}
}
func TestContextualMatchScore_CurrentSeed_Returns0(t *testing.T) {
current := SessionVector{Seed: true}
likes := []SessionVector{
{Artists: []string{"a"}, Tags: map[string]int{"rock": 1}},
}
got := ContextualMatchScore(current, likes, DefaultSimilarityWeights)
if !approxEq(got, 0.0) {
t.Errorf("current seed = %v, want 0.0", got)
}
}
func TestContextualMatchScore_AllLikesSeed_Returns0(t *testing.T) {
current := SessionVector{Artists: []string{"a"}, Tags: map[string]int{"rock": 1}}
likes := []SessionVector{
{Seed: true, Artists: []string{"a"}, Tags: map[string]int{"rock": 1}},
{Seed: true, Artists: []string{"a"}, Tags: map[string]int{"rock": 1}},
}
got := ContextualMatchScore(current, likes, DefaultSimilarityWeights)
if !approxEq(got, 0.0) {
t.Errorf("all-seed likes = %v, want 0.0", got)
}
}
func TestContextualMatchScore_TakesMax(t *testing.T) {
// Three likes: full match, partial match, mismatch. Expect full match (1.0).
current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
likes := []SessionVector{
{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}, // 1.0
{Artists: []string{"a2"}, Tags: map[string]int{"rock": 1}}, // 0.7
{Artists: []string{"a99"}, Tags: map[string]int{"jazz": 1}}, // 0.0
}
got := ContextualMatchScore(current, likes, DefaultSimilarityWeights)
if !approxEq(got, 1.0) {
t.Errorf("takes-max = %v, want 1.0", got)
}
}
func TestContextualMatchScore_FiltersSeedThenMaxes(t *testing.T) {
// One Seed=true match (would be 1.0 if not filtered) + one partial match.
current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
likes := []SessionVector{
{Seed: true, Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}},
{Artists: []string{"a2"}, Tags: map[string]int{"rock": 1}},
}
got := ContextualMatchScore(current, likes, DefaultSimilarityWeights)
// Seed=true filtered out → only partial match counts → 0.7
if !approxEq(got, 0.7) {
t.Errorf("filter-then-max = %v, want 0.7", got)
}
}