From a7211aacff54dfd85bd37a1a48cef38704e45fb6 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 27 Apr 2026 20:04:35 -0400 Subject: [PATCH] feat(recommendation): add pure Similarity function with weighted Jaccard --- internal/recommendation/similarity.go | 76 +++++++++++++++++ internal/recommendation/similarity_test.go | 94 ++++++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 internal/recommendation/similarity.go create mode 100644 internal/recommendation/similarity_test.go diff --git a/internal/recommendation/similarity.go b/internal/recommendation/similarity.go new file mode 100644 index 00000000..6eab99a0 --- /dev/null +++ b/internal/recommendation/similarity.go @@ -0,0 +1,76 @@ +package recommendation + +// SimilarityWeights balances the per-axis contribution to the weighted Jaccard +// score. v1 hardcodes the defaults — operators cannot tune via YAML. If +// telemetry justifies it, expose under recommendation.similarity.* later. +type SimilarityWeights struct { + TagsWeight float64 + ArtistsWeight float64 +} + +// DefaultSimilarityWeights is the v1 axis balance per the M3 design. +// Tags carry more signal than artists because a session's "vibe" tracks +// genre more directly than artist identity (a session can mix artists +// within a genre but rarely mixes genres). +var DefaultSimilarityWeights = SimilarityWeights{ + TagsWeight: 0.7, + ArtistsWeight: 0.3, +} + +// Similarity returns weighted-Jaccard similarity in [0, 1] between two +// session vectors. Returns 0 if either input is Seed=true (low-confidence +// vectors don't contribute to scoring). +func Similarity(a, b SessionVector, w SimilarityWeights) float64 { + if a.Seed || b.Seed { + return 0.0 + } + tagJ := setJaccardKeys(a.Tags, b.Tags) + artistJ := setJaccardSlice(a.Artists, b.Artists) + return tagJ*w.TagsWeight + artistJ*w.ArtistsWeight +} + +// setJaccardKeys collapses two map keysets to sets and returns +// |A ∩ B| / |A ∪ B|. Both empty → 0 (not NaN). +func setJaccardKeys(a, b map[string]int) float64 { + if len(a) == 0 && len(b) == 0 { + return 0.0 + } + intersect := 0 + for k := range a { + if _, ok := b[k]; ok { + intersect++ + } + } + union := len(a) + len(b) - intersect + if union == 0 { + return 0.0 + } + return float64(intersect) / float64(union) +} + +// setJaccardSlice deduplicates each input slice into a set and returns +// |A ∩ B| / |A ∪ B|. Both empty → 0 (not NaN). +func setJaccardSlice(a, b []string) float64 { + if len(a) == 0 && len(b) == 0 { + return 0.0 + } + aset := make(map[string]struct{}, len(a)) + for _, x := range a { + aset[x] = struct{}{} + } + bset := make(map[string]struct{}, len(b)) + for _, x := range b { + bset[x] = struct{}{} + } + intersect := 0 + for k := range aset { + if _, ok := bset[k]; ok { + intersect++ + } + } + union := len(aset) + len(bset) - intersect + if union == 0 { + return 0.0 + } + return float64(intersect) / float64(union) +} diff --git a/internal/recommendation/similarity_test.go b/internal/recommendation/similarity_test.go new file mode 100644 index 00000000..78ddb774 --- /dev/null +++ b/internal/recommendation/similarity_test.go @@ -0,0 +1,94 @@ +package recommendation + +import ( + "math" + "testing" +) + +func approxEq(a, b float64) bool { return math.Abs(a-b) < 1e-9 } + +func TestSimilarity_IdenticalVectors_Returns1(t *testing.T) { + v := SessionVector{ + Artists: []string{"a1", "a2"}, + Tags: map[string]int{"rock": 2, "indie": 1}, + } + got := Similarity(v, v, DefaultSimilarityWeights) + if !approxEq(got, 1.0) { + t.Errorf("Similarity(v,v) = %v, want 1.0", got) + } +} + +func TestSimilarity_FullyDisjoint_Returns0(t *testing.T) { + a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} + b := SessionVector{Artists: []string{"a2"}, Tags: map[string]int{"jazz": 1}} + got := Similarity(a, b, DefaultSimilarityWeights) + if !approxEq(got, 0.0) { + t.Errorf("disjoint = %v, want 0.0", got) + } +} + +func TestSimilarity_TagsOnlyShared_AppliesTagsWeight(t *testing.T) { + a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} + b := SessionVector{Artists: []string{"a2"}, Tags: map[string]int{"rock": 5}} + got := Similarity(a, b, DefaultSimilarityWeights) + if !approxEq(got, 0.7) { + t.Errorf("tags-only = %v, want 0.7", got) + } +} + +func TestSimilarity_ArtistsOnlyShared_AppliesArtistsWeight(t *testing.T) { + a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} + b := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"jazz": 1}} + got := Similarity(a, b, DefaultSimilarityWeights) + if !approxEq(got, 0.3) { + t.Errorf("artists-only = %v, want 0.3", got) + } +} + +func TestSimilarity_EitherSeed_Returns0(t *testing.T) { + v := SessionVector{Artists: []string{"a"}, Tags: map[string]int{"rock": 1}} + seed := SessionVector{Seed: true, Artists: []string{"a"}, Tags: map[string]int{"rock": 1}} + if got := Similarity(v, seed, DefaultSimilarityWeights); !approxEq(got, 0.0) { + t.Errorf("v vs seed = %v, want 0.0", got) + } + if got := Similarity(seed, v, DefaultSimilarityWeights); !approxEq(got, 0.0) { + t.Errorf("seed vs v = %v, want 0.0", got) + } +} + +func TestSimilarity_BothEmpty_Returns0NotNaN(t *testing.T) { + a := SessionVector{} + b := SessionVector{} + got := Similarity(a, b, DefaultSimilarityWeights) + if math.IsNaN(got) || !approxEq(got, 0.0) { + t.Errorf("empty = %v, want 0.0 (not NaN)", got) + } +} + +func TestSimilarity_OneAxisEmptyOneSide_AxisContributesZero(t *testing.T) { + a := SessionVector{Tags: map[string]int{"rock": 1}} + b := SessionVector{Artists: []string{"a1"}} + got := Similarity(a, b, DefaultSimilarityWeights) + if !approxEq(got, 0.0) { + t.Errorf("one-axis-each = %v, want 0.0", got) + } +} + +func TestSimilarity_PartialTagsOverlap(t *testing.T) { + a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1, "indie": 1}} + b := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1, "jazz": 1}} + got := Similarity(a, b, DefaultSimilarityWeights) + want := 0.7*(1.0/3.0) + 0.3*1.0 + if !approxEq(got, want) { + t.Errorf("partial = %v, want %v", got, want) + } +} + +func TestSimilarity_BagOfCountsCollapsesToSet(t *testing.T) { + a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 2, "indie": 1}} + b := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 5, "indie": 3}} + got := Similarity(a, b, DefaultSimilarityWeights) + if !approxEq(got, 1.0) { + t.Errorf("set-collapse = %v, want 1.0", got) + } +}