fix: ListenBrainz similarity is stored relative to the seed's best, and the candidate arms keep tier order (#6177)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m36s
release / integration (push) Successful in 5m4s
release / android (push) Successful in 5m5s
release / Build signed APK (releases and dev) (push) Successful in 5m18s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 30s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m36s
release / integration (push) Successful in 5m4s
release / android (push) Successful in 5m5s
release / Build signed APK (releases and dev) (push) Successful in 5m18s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 30s
release / Verify release artifacts (tag releases only) (push) Skipped
LB's similarity scores are counts (16 to 1445 for recordings, to 10,875 for artists), and they reached a scorer that reads similarity as 0 to 1. One edge added 64 to 5,800 points under Songs-like, so likes, skips and recency stopped mattering for any candidate with an edge. - ResolveListenBrainzTrackEdges divides each edge by the seed's best score in its whole cached answer. The cache keeps the raw answer. - The worker stores matched and unmatched artist rows over the seed's top artist. - Migration 0096 rescales the rows already stored. - The candidate arms hold sim_score bands so tier 1 stays above tier 2: lb_similar 0.5 + 0.5 x edge, tag_overlap halved, likes_overlap 0.3. Otherwise a seed's weaker LB edges fell below a shared genre tag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -127,8 +127,9 @@ func DefaultCandidateSourceLimits() CandidateSourceLimits {
|
||||
// tier 3 taste_overlap, random_fill
|
||||
// seed-independent. The floor, and nothing more.
|
||||
//
|
||||
// The tiering is enforced by SCORE rather than by a fallback ladder: tier-3
|
||||
// arms carry sim_score 0, so under SongsLike weights (SimilarityWeight 4.0,
|
||||
// The tiering is enforced by SCORE rather than by a fallback ladder: each tier
|
||||
// holds its own sim_score band (recommendation.sql; tier 1 above 0.5, tier 2
|
||||
// at or below it, #6177), and tier-3 arms carry sim_score 0, so under SongsLike weights (SimilarityWeight 4.0,
|
||||
// everything seed-independent demoted) they rank below any real match and
|
||||
// surface only when tiers 1–2 cannot fill the mix. That is the rule's
|
||||
// "fill from tier 1 first, reach down only when a tier cannot fill".
|
||||
@@ -159,19 +160,19 @@ func DefaultCandidateSourceLimits() CandidateSourceLimits {
|
||||
// surface only when the closer tiers cannot fill the mix.
|
||||
//
|
||||
// likes_overlap is cut hardest of the tier-2 arms for a specific reason: its
|
||||
// SQL assigns a FLAT 0.6 sim_score (recommendation.sql) rather than measuring
|
||||
// SQL assigns a FLAT 0.3 sim_score (recommendation.sql) rather than measuring
|
||||
// anything. It is a collaborative signal wearing similarity's clothes, and a
|
||||
// raised SimilarityWeight amplifies it — if real ListenBrainz scores commonly
|
||||
// land below 0.6 it would outrank genuine matches. Halved pending the
|
||||
// fill-rate measurement in #3879; the honest fix is to stop it claiming a
|
||||
// similarity score it never computed.
|
||||
// raised SimilarityWeight amplifies it. The tier bands keep it below every
|
||||
// ListenBrainz match (#6177); halved pending the fill-rate measurement in
|
||||
// #3879, and the honest fix is still to stop it claiming a similarity score
|
||||
// it never computed.
|
||||
func SongsLikeCandidateSourceLimits() CandidateSourceLimits {
|
||||
return CandidateSourceLimits{
|
||||
LBSimilar: 60, // tier 1 — doubled; the only arm that measures the seed
|
||||
SimilarArtist: 40, // tier 2 — raised; growing is always safe
|
||||
TagOverlap: 20, // tier 2
|
||||
UserCoplay: 20, // tier 2
|
||||
LikesOverlap: 10, // tier 2, halved — flat 0.6 sim_score, see above
|
||||
LikesOverlap: 10, // tier 2, halved — flat 0.3 sim_score, see above
|
||||
TasteOverlap: 10, // tier 3 floor — halved, not removed
|
||||
RandomFill: 10, // tier 3 floor — cut hard, never to zero
|
||||
}
|
||||
|
||||
@@ -69,8 +69,9 @@ func TestLoadCandidatesFromSimilarity_LBSimilarSourceContributes(t *testing.T) {
|
||||
if found == nil {
|
||||
t.Fatal("LB-similar target missing from candidates")
|
||||
}
|
||||
if found.Inputs.SimilarityScore < 0.84 || found.Inputs.SimilarityScore > 0.86 {
|
||||
t.Errorf("LB-similar SimilarityScore = %v, want ~0.85", found.Inputs.SimilarityScore)
|
||||
// Tier 1's band (#6177): 0.5 + 0.5 × 0.85.
|
||||
if found.Inputs.SimilarityScore < 0.92 || found.Inputs.SimilarityScore > 0.93 {
|
||||
t.Errorf("LB-similar SimilarityScore = %v, want ~0.925 (0.5 + 0.5 × 0.85)", found.Inputs.SimilarityScore)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,9 +118,9 @@ func TestLoadCandidatesFromSimilarity_TagOverlapContributes(t *testing.T) {
|
||||
}
|
||||
for _, c := range got {
|
||||
if c.Track.ID == target.ID {
|
||||
// Seed has 2 tags; target shares 1 → jaccard 1/2 = 0.5.
|
||||
if c.Inputs.SimilarityScore < 0.49 || c.Inputs.SimilarityScore > 0.51 {
|
||||
t.Errorf("tag-overlap SimilarityScore = %v, want ~0.5", c.Inputs.SimilarityScore)
|
||||
// Seed has 2 tags; target shares 1 → 1/2, in tier 2's band → 0.25.
|
||||
if c.Inputs.SimilarityScore < 0.24 || c.Inputs.SimilarityScore > 0.26 {
|
||||
t.Errorf("tag-overlap SimilarityScore = %v, want ~0.25 (0.5 × 1/2)", c.Inputs.SimilarityScore)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -144,10 +145,10 @@ func TestLoadCandidatesFromSimilarity_LikesOverlapContributes(t *testing.T) {
|
||||
}
|
||||
for _, c := range got {
|
||||
if c.Track.ID == liked.ID {
|
||||
// Both tracks tagged "Rock" → jaccard 1/1 = 1.0 from tag-overlap.
|
||||
// likes-overlap = 0.6. Max wins = 1.0.
|
||||
if c.Inputs.SimilarityScore < 0.59 {
|
||||
t.Errorf("likes-overlap candidate SimilarityScore = %v, want ≥ 0.6", c.Inputs.SimilarityScore)
|
||||
// Both tracks tagged "Rock" → 0.5 × 1/1 = 0.5 from tag-overlap.
|
||||
// likes-overlap = 0.3. Max wins = 0.5.
|
||||
if c.Inputs.SimilarityScore < 0.29 {
|
||||
t.Errorf("likes-overlap candidate SimilarityScore = %v, want ≥ 0.3", c.Inputs.SimilarityScore)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -243,8 +244,8 @@ func TestLoadCandidatesFromSimilarity_DedupTakesMaxScore(t *testing.T) {
|
||||
seed := f.tracks[0]
|
||||
target := f.tracks[1]
|
||||
helperSetTrackGenre(t, f, seed.ID, "Rock")
|
||||
helperSetTrackGenre(t, f, target.ID, "Rock") // jaccard 1/1 = 1.0 from tag-overlap
|
||||
helperLBSimilarity(t, f, seed.ID, target.ID, 0.5) // weaker LB signal
|
||||
helperSetTrackGenre(t, f, target.ID, "Rock") // 0.5 × 1/1 = 0.5 from tag-overlap
|
||||
helperLBSimilarity(t, f, seed.ID, target.ID, 0.1) // a weak LB edge → 0.5 + 0.05
|
||||
got, err := LoadCandidatesFromSimilarity(
|
||||
context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), "test-seed",
|
||||
)
|
||||
@@ -255,9 +256,10 @@ func TestLoadCandidatesFromSimilarity_DedupTakesMaxScore(t *testing.T) {
|
||||
for _, c := range got {
|
||||
if c.Track.ID == target.ID {
|
||||
count++
|
||||
// tag-overlap (1.0) wins over LB (0.5) per max() — expect ≥ 0.99
|
||||
if c.Inputs.SimilarityScore < 0.99 {
|
||||
t.Errorf("dedup max SimilarityScore = %v, want ≥ 0.99 (tag-overlap should win)", c.Inputs.SimilarityScore)
|
||||
// max() keeps LB's 0.55. Even a weak ListenBrainz edge is tier 1 and
|
||||
// outranks a full tag match (#6177).
|
||||
if c.Inputs.SimilarityScore < 0.54 || c.Inputs.SimilarityScore > 0.56 {
|
||||
t.Errorf("dedup max SimilarityScore = %v, want ~0.55 (the LB edge's band should win)", c.Inputs.SimilarityScore)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user