Files
minstrel/internal/recommendation/similarity.go
T
bvandeusenandClaude Opus 5.5 466912e202
release / govulncheck (push) Successful in 25s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m47s
release / integration (push) Successful in 4m35s
release / android (push) Successful in 7m5s
release / Build signed APK (releases and dev) (push) Successful in 7m16s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m1s
release / Verify release artifacts (tag releases only) (push) Skipped
docs: plan-era "v1", "Task N" and "Phase N" wording restated as facts (#6277)
The marker list behind the earlier server and web passes did not catch
"for v1", "Task 5", "Phase 2" or "today: Flutter". Those comments now say
what the code does.

Corrected: coverart said "both v1 providers" ship on. There are four
providers (MBCAA, TheAudioDB, Deezer, Last.fm), and Last.fm, which needs a
key, ships off.

Non-comment tokens unchanged against HEAD: 10 Go files, 7 TS/Svelte files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:05:03 -04:00

101 lines
2.8 KiB
Go
Raw 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. The defaults are hardcoded; there is no config key for them.
type SimilarityWeights struct {
TagsWeight float64
ArtistsWeight float64
}
// DefaultSimilarityWeights is the shipped axis balance.
// 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
}