MusicBrainz: alias trust-gate evaluates MB-score leader, not combined leader
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 <noreply@anthropic.com>
This commit is contained in:
parent
70732ad80e
commit
6cb5d455f9
2 changed files with 63 additions and 15 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Reference in a new issue