From 347884bc2be37b15ecfaf43437d770caf8b3da8c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 27 Apr 2026 20:20:53 -0400 Subject: [PATCH] feat(recommendation): add ContextualMatchScore (max over non-seed likes) --- internal/recommendation/similarity.go | 25 +++++++++ internal/recommendation/similarity_test.go | 63 ++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/internal/recommendation/similarity.go b/internal/recommendation/similarity.go index 6eab99a0..3062a7f3 100644 --- a/internal/recommendation/similarity.go +++ b/internal/recommendation/similarity.go @@ -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 +} diff --git a/internal/recommendation/similarity_test.go b/internal/recommendation/similarity_test.go index 78ddb774..32b3f4e6 100644 --- a/internal/recommendation/similarity_test.go +++ b/internal/recommendation/similarity_test.go @@ -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) + } +}