Files

102 lines
2.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}