feat(discover): max-normalize similarity scores across sources

_record_contributions now normalizes each source's top-N to [0,1] by its own
top score before summing across sources. ListenBrainz co-occurrence counts
(thousands) no longer drown Last.fm's 0–1 match scores, so a small-scale
source contributes real ranking signal instead of only adding candidates.

Discovery score assertions updated to the normalized scale (single-candidate
fakes normalize to 1.0); added a cross-source test showing a small-scale
source's candidate ranks equal to a large-scale one. min_score is now on the
[0, n_sources] scale (default 0.0 → unaffected). worker 231 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-14 21:43:02 +02:00
parent 0f64e3c83a
commit 1943fe2bdf
2 changed files with 39 additions and 11 deletions
+9 -2
View File
@@ -181,11 +181,18 @@ def _record_contributions(conn: psycopg.Connection, seed_mbid: str, live, follow
except Exception as e: # a bad source/seed must not abort the sweep
print(f"worker: discovery source {src.name} failed for {seed_mbid}: {e}", flush=True)
continue
for sa in sorted(similar, key=lambda a: a.score, reverse=True)[: cfg.similar_per_seed]:
top = sorted(similar, key=lambda a: a.score, reverse=True)[: cfg.similar_per_seed]
# Max-normalize each source to [0, 1] by its own top score before summing across
# sources, so sources on wildly different scales contribute comparably (Last.fm's
# `match` is 01; ListenBrainz co-occurrence counts run to the thousands — raw sums
# let ListenBrainz drown Last.fm's ranking signal).
max_score = max((sa.score for sa in top), default=0.0)
for sa in top:
if sa.mbid in followed:
continue
norm = sa.score / max_score if max_score > 0 else 0.0
c = per_cand.setdefault(sa.mbid, {"name": sa.name, "score": 0.0, "sources": set()})
c["score"] += sa.score
c["score"] += norm
c["sources"].add(src.name)
if sa.name and not c["name"]:
c["name"] = sa.name