discovery-by-llm/backend/tests/test_grounding.py

307 lines
9.2 KiB
Python

"""Deterministic tests for bounded Spotify grounding."""
import asyncio
import time
from collections.abc import Awaitable, Callable
from app.adapters.spotify.errors import (
SpotifyQuotaExhaustedError,
SpotifyRateLimitedError,
SpotifyUnavailableError,
)
from app.config import Settings
from app.domain.models import Familiarity, Track, TrackCandidate
from app.pipeline.grounding import Grounder
from app.ports.protocols import TimeRange
SearchHandler = Callable[[str], Awaitable[list[Track]]]
class FakeCatalog:
"""Expose a programmable search surface for grounding tests."""
def __init__(self, search_handler: SearchHandler) -> None:
"""Store the search behavior and call trace."""
self.search_handler = search_handler
self.search_queries: list[str] = []
async def search_tracks(self, query: str, limit: int = 10) -> list[Track]:
"""Record and delegate one fake search."""
self.search_queries.append(query)
return await self.search_handler(query)
async def fetch_top_artists(self, time_range: TimeRange, limit: int) -> list[str]:
"""Return no top artists."""
return []
async def fetch_top_tracks(self, time_range: TimeRange, limit: int) -> list[Track]:
"""Return no top tracks."""
return []
async def fetch_saved_tracks(self, limit: int) -> list[Track]:
"""Return no saved tracks."""
return []
def test_early_stop_honors_pool_target() -> None:
async def run() -> None:
async def search(query: str) -> list[Track]:
title = query.split('track:"', 1)[1].split('"', 1)[0]
return [_track(title, title, "Artist")]
catalog = FakeCatalog(search)
grounder = Grounder(_settings(grounding_concurrency=2))
candidates = tuple(TrackCandidate(f"track-{index}", "Artist") for index in range(6))
result = await grounder.ground(
catalog,
candidates,
frozenset(),
Familiarity.MIX,
2,
_deadline(),
)
assert len(result.tracks) == 2
assert len(catalog.search_queries) == 2
asyncio.run(run())
def test_miss_and_mismatch_are_counted_separately() -> None:
async def run() -> None:
async def search(query: str) -> list[Track]:
if "Missing" in query:
return []
return [_track("wrong", "Different Song", "Different Artist")]
catalog = FakeCatalog(search)
candidates = (
TrackCandidate("Missing", "Artist"),
TrackCandidate("Rejected", "Artist"),
)
result = await Grounder(_settings()).ground(
catalog,
candidates,
frozenset(),
Familiarity.MIX,
2,
_deadline(),
)
assert result.metrics.miss_count == 1
assert result.metrics.mismatch_guard_count == 1
assert result.metrics.attempted_count == 2
asyncio.run(run())
def test_resolution_cache_hit_skips_catalog() -> None:
async def run() -> None:
async def search(query: str) -> list[Track]:
return [_track("cached", "Cached Song", "Artist")]
catalog = FakeCatalog(search)
grounder = Grounder(_settings())
candidates = (TrackCandidate("Cached Song", "Artist"),)
await grounder.ground(
catalog,
candidates,
frozenset(),
Familiarity.MIX,
1,
_deadline(),
)
first_call_count = len(catalog.search_queries)
second = await grounder.ground(
catalog,
candidates,
frozenset(),
Familiarity.MIX,
1,
_deadline(),
)
assert len(catalog.search_queries) == first_call_count
assert second.metrics.cache_hit_count == 1
asyncio.run(run())
def test_deadline_returns_resolved_partial_pool() -> None:
async def run() -> None:
never_finishes = asyncio.Event()
async def search(query: str) -> list[Track]:
if "Slow Song" in query:
await never_finishes.wait()
return [_track("fast", "Fast Song", "Artist")]
catalog = FakeCatalog(search)
settings = _settings(grounding_concurrency=2, request_deadline_seconds=0.02)
candidates = (
TrackCandidate("Fast Song", "Artist"),
TrackCandidate("Slow Song", "Artist"),
)
result = await Grounder(settings).ground(
catalog,
candidates,
frozenset(),
Familiarity.MIX,
2,
time.monotonic() + settings.request_deadline_seconds,
)
assert [track.id for track in result.tracks] == ["fast"]
assert result.metrics.did_reach_deadline
asyncio.run(run())
def test_spotify_failure_is_counted_and_remaining_candidates_continue() -> None:
async def run() -> None:
async def search(query: str) -> list[Track]:
if "Failing Song" in query:
raise SpotifyUnavailableError(503)
title = query.split('track:"', 1)[1].split('"', 1)[0]
return [_track(title.lower().replace(" ", "-"), title, "Artist")]
catalog = FakeCatalog(search)
candidates = (
TrackCandidate("Failing Song", "Artist"),
TrackCandidate("First Good Song", "Artist"),
TrackCandidate("Second Good Song", "Artist"),
)
result = await Grounder(_settings()).ground(
catalog,
candidates,
frozenset(),
Familiarity.MIX,
2,
_deadline(),
)
assert [track.title for track in result.tracks] == [
"First Good Song",
"Second Good Song",
]
assert result.metrics.failed_count == 1
assert result.metrics.attempted_count == 3
assert not result.metrics.did_exhaust_quota
asyncio.run(run())
def test_plain_rate_limit_continues_but_quota_exhaustion_stops_fanout() -> None:
async def run() -> None:
async def plain_rate_limited_search(query: str) -> list[Track]:
if "Rate Limited" in query:
raise SpotifyRateLimitedError(6.0)
return [_track("found", "Found Song", "Artist")]
plain_catalog = FakeCatalog(plain_rate_limited_search)
candidates = (
TrackCandidate("Rate Limited", "Artist"),
TrackCandidate("Found Song", "Artist"),
)
plain_result = await Grounder(_settings()).ground(
plain_catalog,
candidates,
frozenset(),
Familiarity.MIX,
1,
_deadline(),
)
async def quota_search(query: str) -> list[Track]:
raise SpotifyQuotaExhaustedError(0.0, "QUOTA_EXCEEDED")
quota_catalog = FakeCatalog(quota_search)
quota_result = await Grounder(_settings()).ground(
quota_catalog,
candidates,
frozenset(),
Familiarity.MIX,
1,
_deadline(),
)
assert [track.id for track in plain_result.tracks] == ["found"]
assert plain_result.metrics.failed_count == 1
assert not plain_result.metrics.did_exhaust_quota
assert quota_result.tracks == ()
assert quota_result.metrics.did_exhaust_quota
assert len(quota_catalog.search_queries) == 1
asyncio.run(run())
def test_grounding_concurrency_is_shared_across_requests() -> None:
async def run() -> None:
active_searches = 0
maximum_active_searches = 0
async def search(query: str) -> list[Track]:
nonlocal active_searches, maximum_active_searches
active_searches += 1
maximum_active_searches = max(maximum_active_searches, active_searches)
await asyncio.sleep(0.01)
active_searches -= 1
title = query.split('track:"', 1)[1].split('"', 1)[0]
return [_track(title, title, "Artist")]
catalog = FakeCatalog(search)
grounder = Grounder(_settings(grounding_concurrency=2))
candidates = tuple(TrackCandidate(f"Song {index}", "Artist") for index in range(2))
await asyncio.gather(
grounder.ground(
catalog,
candidates,
frozenset(),
Familiarity.MIX,
2,
_deadline(),
),
grounder.ground(
catalog,
candidates,
frozenset(),
Familiarity.MIX,
2,
_deadline(),
),
)
assert maximum_active_searches == 2
asyncio.run(run())
def _settings(**overrides: object) -> Settings:
values: dict[str, object] = {
"grounding_concurrency": 1,
"request_deadline_seconds": 1.0,
}
values.update(overrides)
return Settings.model_validate(values)
def _deadline() -> float:
return time.monotonic() + 1.0
def _track(track_id: str, title: str, artist: str) -> Track:
return Track(
id=track_id,
uri=f"spotify:track:{track_id}",
title=title,
artists=(artist,),
album_name="Album",
album_art_url=None,
external_url=None,
)