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) } // 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 }