soulsync/tests/matching/test_acoustid_verification_aliases.py
Broque Thomas 11397307b2 Alias resolution polish: lazy-fire on direct-match failure + worker backfill
Two perf gaps that would have failed Cin's review:

# Gap #1: alias lookup fired unconditionally

Pre-fix in this commit, `_resolve_expected_artist_aliases` ran at
the top of every `verify_audio_file` call regardless of whether
the direct artist match would have passed. For users whose library
is mostly same-script (95% of cases), every successful verification
was paying for a wasted DB query (and possibly a wasted MB API
call for un-enriched artists).

Restructured the helper to accept a callable provider instead of a
pre-resolved list. Provider invoked LAZILY only when direct
similarity falls below `ARTIST_MATCH_THRESHOLD`. Verifier passes a
memoising thunk that resolves once across the 3 comparison sites
within one verification.

`_alias_aware_artist_sim` now accepts `aliases` as either:

- iterable of strings (used eagerly — backward compat with tests
  that already know the aliases)
- callable returning the iterable (resolved on first need within
  a verification)

Happy path (direct match passes): zero DB queries, zero MB calls.
Cross-script case: one resolution shared across 3 sites — same as
the prior contract.

# Gap #2: existing-MBID artists never got alias backfill

Worker's `_process_item` artist branch had an `existing_id` short-
circuit (line 296) that updated MBID status but skipped alias
fetch. Result: every user with an already-enriched library had
MBIDs but NULL aliases on day-one of this PR. Live MB lookup at
verify-time covered them, but at the cost of N live calls for N
artists across the library.

Added one-time backfill: when existing-MBID is found AND
`artists.aliases` for that row is empty, fetch + persist aliases.
Subsequent re-scan cycles short-circuit on the populated column —
no repeated MB calls.

New helper `_artist_aliases_empty(artist_id)` does the cheap NULL
check via direct SQL. Best-effort: defensively returns True on
errors so backfill happens (a redundant MB call is cheaper than
missing the backfill entirely).

# Tests added (9)

`test_acoustid_verification_aliases.py` (+6):
- `TestLazyAliasResolution` (3): no lookup when direct match passes,
  lookup fires only when direct fails, lookup memoised across the
  3 sites within one verification.
- `TestAliasProviderCallable` (3): iterable passed directly,
  callable resolves lazily, callable returning empty falls back to
  direct sim.

`test_artist_alias_service.py` (+3):
- `test_existing_mbid_path_backfills_aliases_when_column_empty`
- `test_existing_mbid_path_skips_backfill_when_aliases_already_set`
- `test_existing_mbid_backfill_failure_does_not_break_match`

# Verification

- 79/79 matching tests pass (+9 from prior commit)
- 2537 full suite passes (+9, +79 PR-total)
- Ruff clean
- Backward compat: every prior-commit test still passes (the
  iterable-shape API still works alongside the new callable shape)
2026-05-10 17:02:02 -07:00

401 lines
16 KiB
Python

"""Regression tests for issue #442 — AcoustID verifier alias awareness.
The reporter posted two exact cases:
Case 1 (Japanese kanji ↔ romanized):
File: YAMANAIAME by 澤野弘之
Expected: YAMANAIAME by Hiroyuki Sawano
Pre-fix: quarantined (artist_sim=0%)
Post-fix: passes verification because MB aliases bridge the
two spellings.
Case 2 (Cyrillic ↔ Latin):
File: On the Other Side by Sergey Lazarev
Expected: On the other side by Сергей Лазарев
Pre-fix: quarantined (artist_sim=7%)
Post-fix: passes via aliases.
These tests pin the verifier through the helper. AcoustID's
fingerprint call is stubbed (no network), MB service's
`lookup_artist_aliases` is stubbed to return the relevant aliases.
The verifier's pass/fail decision is the assertion.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from core.acoustid_verification import (
AcoustIDVerification,
VerificationResult,
_alias_aware_artist_sim,
_find_best_title_artist_match,
)
# ---------------------------------------------------------------------------
# Pure helper — _alias_aware_artist_sim
# ---------------------------------------------------------------------------
class TestAliasAwareArtistSim:
def test_returns_higher_score_when_alias_matches(self):
score = _alias_aware_artist_sim(
'Hiroyuki Sawano', '澤野弘之',
aliases=['澤野弘之', 'SawanoHiroyuki'],
)
assert score == 1.0
def test_no_aliases_falls_back_to_direct_similarity(self):
"""Cross-script with NO aliases → score ~0, pre-fix behaviour."""
score = _alias_aware_artist_sim(
'Hiroyuki Sawano', '澤野弘之', aliases=None,
)
assert score < 0.1
def test_aliases_dont_mask_genuine_mismatch(self):
"""Different artist entirely → still scores low even when
aliases are provided. Aliases bridge synonyms, not unrelated
artists."""
score = _alias_aware_artist_sim(
'Hiroyuki Sawano', 'Khalil Turk & Friends',
aliases=['澤野弘之', 'SawanoHiroyuki'],
)
assert score < 0.5
# ---------------------------------------------------------------------------
# _find_best_title_artist_match — accepts aliases now
# ---------------------------------------------------------------------------
class TestFindBestMatchWithAliases:
def test_japanese_alias_picks_correct_recording(self):
"""Reporter's case 1: AcoustID returned recording with kanji
artist. Without aliases the scorer ranks it low and the
verifier later quarantines. With aliases it scores high."""
recordings = [
{'title': 'YAMANAIAME', 'artist': '澤野弘之'},
{'title': 'Different Song', 'artist': 'Hiroyuki Sawano'},
]
# Aliases provided — bridge to recording 0
best, title_sim, artist_sim = _find_best_title_artist_match(
recordings, 'YAMANAIAME', 'Hiroyuki Sawano',
expected_artist_aliases=['澤野弘之', 'SawanoHiroyuki'],
)
assert best is recordings[0]
assert artist_sim == 1.0
def test_no_aliases_legacy_behaviour_preserved(self):
"""Default arg / empty aliases → identical to pre-fix
behaviour. Critical for paths not yet wired up to alias
lookup."""
recordings = [
{'title': 'Track', 'artist': 'Artist'},
]
best, title_sim, artist_sim = _find_best_title_artist_match(
recordings, 'Track', 'Artist',
)
assert title_sim == 1.0
assert artist_sim == 1.0
# ---------------------------------------------------------------------------
# End-to-end — reporter's cases through the full verifier
# ---------------------------------------------------------------------------
@pytest.fixture
def stubbed_verifier(monkeypatch):
"""AcoustIDVerification with the acoustid client + MB service
layer stubbed. Lets us drive the verifier's full decision path
without network or DB. Returns the verifier + mutable handles
to the stubs so each test can shape the AcoustID response +
aliases."""
verifier = AcoustIDVerification()
verifier.acoustid_client = MagicMock()
verifier.acoustid_client.is_available.return_value = (True, '')
# Stub the MB service so verifier alias lookup doesn't touch DB
# or network. Each test sets fake_service.lookup_artist_aliases.
fake_service = MagicMock()
fake_service.lookup_artist_aliases.return_value = []
monkeypatch.setattr(
'core.acoustid_verification._get_mb_service', lambda: fake_service,
)
return verifier, fake_service
class TestIssue442Regression:
def test_japanese_kanji_artist_passes_verification(self, stubbed_verifier):
"""Reporter's case 1 — verbatim from the issue:
File: YAMANAIAME by 澤野弘之
Expected: YAMANAIAME by Hiroyuki Sawano
Pre-fix: Quarantined (artist=0%)
"""
verifier, fake_service = stubbed_verifier
# AcoustID returns the recording with kanji artist
verifier.acoustid_client.fingerprint_and_lookup.return_value = {
'best_score': 0.95,
'recordings': [
{'title': 'YAMANAIAME', 'artist': '澤野弘之', 'mbid': 'rec-x'},
],
}
# MB knows Hiroyuki Sawano's aliases
fake_service.lookup_artist_aliases.return_value = [
'澤野弘之', 'SawanoHiroyuki', 'Sawano Hiroyuki',
]
result, msg = verifier.verify_audio_file(
'/fake/path.mp3', 'YAMANAIAME', 'Hiroyuki Sawano',
)
assert result == VerificationResult.PASS, (
f"Reporter's exact case must pass verification post-fix; "
f"got result={result.value!r} msg={msg!r}"
)
fake_service.lookup_artist_aliases.assert_called_once_with('Hiroyuki Sawano')
def test_cyrillic_artist_passes_verification(self, stubbed_verifier):
"""Reporter's case 2 — Sergey Lazarev / Сергей Лазарев."""
verifier, fake_service = stubbed_verifier
verifier.acoustid_client.fingerprint_and_lookup.return_value = {
'best_score': 0.95,
'recordings': [
{'title': 'On the Other Side', 'artist': 'Sergey Lazarev', 'mbid': 'rec-y'},
],
}
fake_service.lookup_artist_aliases.return_value = [
'Sergey Lazarev', 'Sergei Lazarev',
]
result, msg = verifier.verify_audio_file(
'/fake/path.flac', 'On the other side', 'Сергей Лазарев',
)
assert result == VerificationResult.PASS
# ---------------------------------------------------------------------------
# Backward compat — no aliases available → behavior identical to pre-fix
# ---------------------------------------------------------------------------
class TestBackwardCompat:
def test_no_aliases_clear_artist_mismatch_still_fails(self, stubbed_verifier):
"""Pre-fix: clear mismatches (artist sim near 0, NOT a script
difference) should FAIL. Post-fix with empty aliases must
preserve this — aliases bridge synonyms, not unrelated
artists."""
verifier, fake_service = stubbed_verifier
# Wrong artist entirely — Latin script both sides, sim ~0
verifier.acoustid_client.fingerprint_and_lookup.return_value = {
'best_score': 0.95,
'recordings': [
{'title': 'Some Track', 'artist': 'Khalil Turk & Friends'},
],
}
fake_service.lookup_artist_aliases.return_value = [] # No aliases
result, msg = verifier.verify_audio_file(
'/fake/path.mp3', 'Some Track', 'Foreigner',
)
assert result == VerificationResult.FAIL
def test_no_aliases_exact_match_still_passes(self, stubbed_verifier):
"""Exact title + artist match → PASS regardless of aliases."""
verifier, fake_service = stubbed_verifier
verifier.acoustid_client.fingerprint_and_lookup.return_value = {
'best_score': 0.95,
'recordings': [
{'title': 'Dirty White Boy', 'artist': 'Foreigner'},
],
}
fake_service.lookup_artist_aliases.return_value = []
result, _ = verifier.verify_audio_file(
'/fake/path.mp3', 'Dirty White Boy', 'Foreigner',
)
assert result == VerificationResult.PASS
def test_alias_lookup_failure_does_not_break_verification(self, stubbed_verifier):
"""MB service raises → verifier still completes with direct
similarity (pre-fix behaviour preserved)."""
verifier, fake_service = stubbed_verifier
fake_service.lookup_artist_aliases.side_effect = Exception("MB down")
verifier.acoustid_client.fingerprint_and_lookup.return_value = {
'best_score': 0.95,
'recordings': [
{'title': 'Dirty White Boy', 'artist': 'Foreigner'},
],
}
result, _ = verifier.verify_audio_file(
'/fake/path.mp3', 'Dirty White Boy', 'Foreigner',
)
# Should still pass — direct similarity works
assert result == VerificationResult.PASS
# ---------------------------------------------------------------------------
# Performance contract — alias lookup fires ONCE per verification
# ---------------------------------------------------------------------------
class TestAliasLookupCalledOncePerVerify:
def test_single_lookup_call_regardless_of_recordings_count(self, stubbed_verifier):
"""The verifier processes multiple recordings + scans through
them at up to 3 sites — but should only call
`lookup_artist_aliases` ONCE per verify_audio_file invocation.
Otherwise verifying a track with 20 AcoustID recordings could
fire 60+ MB lookups (cached or not, that's wasteful)."""
verifier, fake_service = stubbed_verifier
verifier.acoustid_client.fingerprint_and_lookup.return_value = {
'best_score': 0.95,
'recordings': [
{'title': 'X', 'artist': '澤野弘之'},
{'title': 'X', 'artist': 'SawanoHiroyuki'},
{'title': 'X', 'artist': 'Different Artist'},
],
}
fake_service.lookup_artist_aliases.return_value = ['澤野弘之', 'SawanoHiroyuki']
verifier.verify_audio_file('/fake/path.mp3', 'X', 'Hiroyuki Sawano')
assert fake_service.lookup_artist_aliases.call_count == 1
# ---------------------------------------------------------------------------
# Lazy alias resolution — happy path skips MB lookup entirely
# ---------------------------------------------------------------------------
class TestLazyAliasResolution:
"""Issue #442 perf followup: alias lookup should ONLY fire when
the direct artist comparison fails. Verifications where artist
names already match (the 95% common case for same-script
libraries) must NOT trigger the lookup chain — no wasted DB
query, no wasted MB call."""
def test_no_lookup_when_direct_artist_match_passes(self, stubbed_verifier):
"""Exact-match Latin-script artist passes verification with
zero alias lookups — no DB query, no MB call. Same-script
libraries (the 95% common case) inherit zero perf cost from
this PR."""
verifier, fake_service = stubbed_verifier
verifier.acoustid_client.fingerprint_and_lookup.return_value = {
'best_score': 0.95,
'recordings': [
{'title': 'Dirty White Boy', 'artist': 'Foreigner'},
],
}
result, _ = verifier.verify_audio_file(
'/fake/path.mp3', 'Dirty White Boy', 'Foreigner',
)
assert result == VerificationResult.PASS
# Critical — alias lookup must NOT have been called for the
# happy path. Otherwise every successful verification adds a
# DB query for nothing.
fake_service.lookup_artist_aliases.assert_not_called()
def test_lookup_fires_only_when_direct_artist_match_fails(self, stubbed_verifier):
"""Cross-script case where direct sim is 0% → lookup fires
as expected."""
verifier, fake_service = stubbed_verifier
verifier.acoustid_client.fingerprint_and_lookup.return_value = {
'best_score': 0.95,
'recordings': [
{'title': 'YAMANAIAME', 'artist': '澤野弘之'},
],
}
fake_service.lookup_artist_aliases.return_value = ['澤野弘之']
result, _ = verifier.verify_audio_file(
'/fake/path.mp3', 'YAMANAIAME', 'Hiroyuki Sawano',
)
assert result == VerificationResult.PASS
# Lookup fired BECAUSE direct match would have failed
fake_service.lookup_artist_aliases.assert_called_once()
def test_lookup_memoised_across_three_comparison_sites(self, stubbed_verifier):
"""When lookup DOES fire, the result must be reused across
the three artist-comparison sites in the verifier (best-match
scoring, secondary scan, fallback scan). One resolution per
verification — not three."""
verifier, fake_service = stubbed_verifier
# Force a code path that hits multiple sites: title matches
# several recordings but the best-match's artist sim is below
# threshold (forces secondary scan path).
verifier.acoustid_client.fingerprint_and_lookup.return_value = {
'best_score': 0.95,
'recordings': [
{'title': 'X', 'artist': 'Different Latin Artist'}, # 0 alias hit
{'title': 'X', 'artist': '澤野弘之'}, # alias hit
],
}
fake_service.lookup_artist_aliases.return_value = ['澤野弘之']
verifier.verify_audio_file('/fake/path.mp3', 'X', 'Hiroyuki Sawano')
# Memoised — one resolution shared across all sites
assert fake_service.lookup_artist_aliases.call_count == 1
# ---------------------------------------------------------------------------
# Provider-callable contract on the helper
# ---------------------------------------------------------------------------
class TestAliasProviderCallable:
"""Pin the dual-shape contract on `_alias_aware_artist_sim`:
accepts an iterable OR a callable. Callable resolves lazily."""
def test_iterable_passed_directly(self):
"""Plain list — used as-is, no lazy semantics."""
score = _alias_aware_artist_sim(
'Hiroyuki Sawano', '澤野弘之', aliases=['澤野弘之'],
)
assert score == 1.0
def test_callable_resolves_lazily_only_when_direct_fails(self):
"""Callable provider — invoked ONLY when direct sim falls
below threshold."""
call_count = [0]
def provider():
call_count[0] += 1
return ['澤野弘之']
# Direct match passes → provider NOT called
_alias_aware_artist_sim('Foreigner', 'Foreigner', aliases=provider)
assert call_count[0] == 0
# Direct match fails → provider IS called
_alias_aware_artist_sim('Hiroyuki Sawano', '澤野弘之', aliases=provider)
assert call_count[0] == 1
def test_callable_returning_empty_list_falls_back_to_direct(self):
"""Provider returns empty (e.g. MB had no aliases) →
score = direct sim, no error."""
score = _alias_aware_artist_sim(
'Hiroyuki Sawano', '澤野弘之', aliases=lambda: [],
)
# ~0 because direct cross-script comparison fails
assert score < 0.1