From 6cb5d455f9d40dafe95302f519ad575b9c1eeb6a Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 5 Jun 2026 21:34:37 +0200 Subject: [PATCH] MusicBrainz: alias trust-gate evaluates MB-score leader, not combined leader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cross-script alias bridge (#442/#586) silently returned [] for some artists ("Sawano Hiroyuki"). Root cause: the mb-only escape — built exactly for the case where local string similarity is ~0 (romaji↔kanji) but MB's own score is decisive — inspected scored[0], the COMBINED-score leader. When an unrelated same-script decoy outranks the real artist on combined score (decoy: sim 0.82 + mb_score 83 → combined 0.82, just under the 0.85 bar; real '澤野弘之': sim 0 + mb_score 100 → combined 0.30, sorted last), the gate saw the decoy's mb_score 83 (< 95), failed both paths, and cached an empty alias result. Verification then scored the kanji artist 0% against the romaji expected name and quarantined every correct file. Evaluate the MB-SCORE leader independently of combined ranking for the mb-only escape, and pull aliases from whichever entity actually passed (combined leader for the combined path, MB-score leader for the mb-only path). The unambiguity check now compares the top two raw MB scores. Same-script and single-result paths are unchanged (regression-guarded by the existing #442/#586 tests). Co-Authored-By: Claude Opus 4.8 --- core/musicbrainz_service.py | 49 +++++++++++++------ .../matching/test_artist_alias_lookup_586.py | 29 +++++++++++ 2 files changed, 63 insertions(+), 15 deletions(-) diff --git a/core/musicbrainz_service.py b/core/musicbrainz_service.py index d9085300..b7d47505 100644 --- a/core/musicbrainz_service.py +++ b/core/musicbrainz_service.py @@ -448,31 +448,50 @@ class MusicBrainzService: scored.sort(key=lambda x: -x[0]) best_score, best_mbid, best_mb_score = scored[0] + # The genuine cross-script match (romaji↔kanji, latin↔cyrillic) + # has near-zero LOCAL similarity, so its COMBINED score sinks + # below an unrelated same-script decoy — even though MB itself is + # certain. "Sawano Hiroyuki": a decoy entity led on combined + # (sim 0.82, mb_score 83, combined 0.82 — just under the 0.85 bar) + # while the real artist '澤野弘之' had mb_score 100 but combined + # 0.30, sorted last. So evaluate the MB-SCORE leader independently + # of the combined ranking for the mb-only escape, not scored[0]. + mb_leader = max(scored, key=lambda x: x[2]) # (combined, mbid, raw_mb) + mb_scores_desc = sorted((x[2] for x in scored), reverse=True) + mb_unambiguous = len(mb_scores_desc) < 2 or (mb_scores_desc[0] - mb_scores_desc[1]) >= 5 + # Trust gate. Two ways to pass: # 1. Combined score >= 0.85 (the historical strict bar that - # catches same-script matches) - # 2. MB's OWN score is very high (>= 95) AND the result is - # unambiguous (top result clearly leads). Bridges the - # cross-script case where local similarity is near zero - # ("Dmitry Yablonsky" vs "Дмитрий Яблонский" sim ~0) - # but MB's index found a high-confidence match. + # catches same-script matches) → trust the combined leader. + # 2. MB's OWN score is very high (>= 95) AND that MB-score leader + # is unambiguous → trust IT. Bridges the cross-script case + # where local similarity is near zero ("Dmitry Yablonsky" vs + # "Дмитрий Яблонский" sim ~0) but MB's index is confident. passes_combined = best_score >= 0.85 - passes_mb_only = best_mb_score >= 95 and ( - len(scored) < 2 or (scored[0][2] - scored[1][2]) >= 5 - ) + passes_mb_only = mb_leader[2] >= 95 and mb_unambiguous if not (passes_combined or passes_mb_only): logger.debug( "lookup_artist_aliases: best match for %r below trust " - "threshold (combined=%.2f, mb_score=%d)", - artist_name, best_score, best_mb_score, + "threshold (combined=%.2f, best_mb=%d, leader_mb=%d)", + artist_name, best_score, best_mb_score, mb_leader[2], ) self._save_to_cache('artist_aliases', artist_name, None, None, {'aliases': []}, 0) return [] + # Pick the entity to pull aliases from. Combined-strong matches use + # the combined leader; the mb-only escape uses the MB-score leader + # (which may differ from scored[0] in the cross-script case above). + if passes_combined: + chosen_mbid, chosen_conf = best_mbid, best_score + else: + chosen_mbid, chosen_conf = mb_leader[1], mb_leader[2] / 100.0 + # Ambiguity detection: when 2+ results both score high (within # 0.1 of the best combined), the search hit multiple distinct # artists with similar names. Pulling aliases for one could - # produce wrong matches. Skip + cache empty. + # produce wrong matches. Skip + cache empty. The unambiguous + # MB-score leader (passes_mb_only) is exempt — its decisiveness + # was already checked via mb_unambiguous. if len(scored) >= 2 and (scored[0][0] - scored[1][0]) < 0.1 and not passes_mb_only: logger.debug( "lookup_artist_aliases: ambiguous match for %r — top " @@ -482,10 +501,10 @@ class MusicBrainzService: self._save_to_cache('artist_aliases', artist_name, None, None, {'aliases': []}, 0) return [] - aliases = self.fetch_artist_aliases(best_mbid) + aliases = self.fetch_artist_aliases(chosen_mbid) self._save_to_cache( - 'artist_aliases', artist_name, None, best_mbid, - {'aliases': aliases}, int(best_score * 100), + 'artist_aliases', artist_name, None, chosen_mbid, + {'aliases': aliases}, int(chosen_conf * 100), ) return aliases diff --git a/tests/matching/test_artist_alias_lookup_586.py b/tests/matching/test_artist_alias_lookup_586.py index 97c9f03d..e67ca03b 100644 --- a/tests/matching/test_artist_alias_lookup_586.py +++ b/tests/matching/test_artist_alias_lookup_586.py @@ -191,6 +191,35 @@ def test_trust_gate_rejects_when_two_high_mb_scores_tie(service): assert aliases == [] +def test_trust_gate_uses_mb_score_leader_not_combined_leader(service): + # Production case "Sawano Hiroyuki": a same-script DECOY entity leads on + # COMBINED score (high local sim, mb_score 83) but sits just under the + # 0.85 combined bar, while the genuine cross-script artist has mb_score + # 100 and ~0 local sim → lowest combined, sorted last. The mb-only escape + # must evaluate the MB-SCORE leader, not scored[0] (the combined leader), + # otherwise it inspects mb_score 83 < 95 and wrongly returns []. + service._calculate_similarity = ( + lambda a, b: 0.82 if b == 'SawanoHiroyuki[nZk]' else 0.0 + ) + service.mb_client.search_artist.return_value = [ + {'id': 'mbid-decoy', 'name': 'SawanoHiroyuki[nZk]', 'score': 83}, + {'id': 'mbid-canonical', 'name': '澤野弘之', 'score': 100}, + ] + + def get_artist(mbid, **kwargs): + if mbid == 'mbid-canonical': + return {'name': '澤野弘之', 'aliases': [{'name': 'Hiroyuki Sawano'}]} + return {'name': 'SawanoHiroyuki[nZk]', 'aliases': []} + service.mb_client.get_artist.side_effect = get_artist + + aliases = service.lookup_artist_aliases('Sawano Hiroyuki') + # The canonical kanji name must come back (its alias set was fetched). + assert '澤野弘之' in aliases + # And we must have fetched the MB-score leader, not the decoy. + service.mb_client.get_artist.assert_called_once() + assert service.mb_client.get_artist.call_args.args[0] == 'mbid-canonical' + + def test_trust_gate_passes_combined_score_when_local_sim_strong(service): # Same-script case from #442 — local sim high. Should still pass # (no regression on the existing path).