77 lines
2.1 KiB
Go
77 lines
2.1 KiB
Go
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)
|
||
}
|