1943fe2bdf
_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>
238 lines
12 KiB
Python
238 lines
12 KiB
Python
from lyra_worker.adapters.fakes import FakeMbBrowser, FakeSimilaritySource
|
||
from lyra_worker.discovery import DiscoveryConfig, run_discovery
|
||
from lyra_worker.similarity.base import SimilarArtist
|
||
from tests.conftest import insert_discovery_suggestion, insert_watched_artist
|
||
|
||
|
||
def _artist_rows(conn):
|
||
with conn.cursor() as cur:
|
||
cur.execute('SELECT "artistMbid", "artistName", score, "seedCount", sources, status::text '
|
||
'FROM "DiscoverySuggestion" WHERE kind = \'artist\' ORDER BY score DESC')
|
||
return cur.fetchall()
|
||
|
||
|
||
def test_aggregates_scores_across_seeds(conn):
|
||
insert_watched_artist(conn, mbid="s1", name="Seed One")
|
||
insert_watched_artist(conn, mbid="s2", name="Seed Two")
|
||
src = FakeSimilaritySource(similar={
|
||
"s1": [SimilarArtist("c1", "Shared", 0.6), SimilarArtist("c2", "Only1", 0.4)],
|
||
"s2": [SimilarArtist("c1", "Shared", 0.5)],
|
||
})
|
||
result = run_discovery(conn, [src], FakeMbBrowser(), DiscoveryConfig())
|
||
assert result.artists == 2
|
||
rows = _artist_rows(conn)
|
||
# c1 recommended by both seeds; each seed max-normalizes its top score to 1.0, so
|
||
# c1 = 1.0 + 1.0 = 2.0, seedCount 2; c2 (0.4/0.6 within s1) by one seed
|
||
assert rows[0][0] == "c1" and abs(rows[0][2] - 2.0) < 1e-6 and rows[0][3] == 2
|
||
assert rows[0][4] == ["fake"] and rows[0][5] == "pending"
|
||
assert rows[1][0] == "c2" and rows[1][3] == 1
|
||
|
||
|
||
def test_normalizes_across_sources_of_different_scales(conn):
|
||
# ListenBrainz emits co-occurrence counts in the thousands; Last.fm emits 0–1 match
|
||
# scores. Max-normalizing each source before summing keeps the small-scale source's
|
||
# candidates from being drowned.
|
||
insert_watched_artist(conn, mbid="s1", name="Seed")
|
||
lb = FakeSimilaritySource(similar={"s1": [
|
||
SimilarArtist("c_lb", "LB Only", 5000.0), SimilarArtist("c_shared", "Shared", 4000.0)]})
|
||
lb.name = "listenbrainz"
|
||
lfm = FakeSimilaritySource(similar={"s1": [
|
||
SimilarArtist("c_lfm", "LFM Only", 0.9), SimilarArtist("c_shared", "Shared", 0.5)]})
|
||
lfm.name = "lastfm"
|
||
run_discovery(conn, [lb, lfm], FakeMbBrowser(), DiscoveryConfig())
|
||
|
||
lb_only = _artist_score(conn, "c_lb")[0] # 5000/5000 = 1.0
|
||
lfm_only = _artist_score(conn, "c_lfm")[0] # 0.9/0.9 = 1.0
|
||
shared = _artist_score(conn, "c_shared")[0] # 4000/5000 + 0.5/0.9 = 0.8 + 0.556
|
||
assert abs(lb_only - lfm_only) < 1e-6 # small-scale source not drowned — ranks equal
|
||
assert shared > lb_only # co-sourced candidate outranks a single-source one
|
||
|
||
|
||
def test_skips_already_followed_artists(conn):
|
||
insert_watched_artist(conn, mbid="s1", name="Seed")
|
||
insert_watched_artist(conn, mbid="c1", name="Already Followed")
|
||
src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "X", 0.9),
|
||
SimilarArtist("c2", "New", 0.8)]})
|
||
run_discovery(conn, [src], FakeMbBrowser(), DiscoveryConfig())
|
||
assert [r[0] for r in _artist_rows(conn)] == ["c2"] # c1 filtered (followed)
|
||
|
||
|
||
def test_min_score_filters_weak_candidates(conn):
|
||
insert_watched_artist(conn, mbid="s1", name="Seed")
|
||
src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "Strong", 0.9),
|
||
SimilarArtist("c2", "Weak", 0.1)]})
|
||
run_discovery(conn, [src], FakeMbBrowser(), DiscoveryConfig(min_score=0.5))
|
||
assert [r[0] for r in _artist_rows(conn)] == ["c1"]
|
||
|
||
|
||
def test_dismissed_suggestion_is_never_revived(conn):
|
||
insert_watched_artist(conn, mbid="s1", name="Seed")
|
||
insert_discovery_suggestion(conn, kind="artist", artist_mbid="c1", status="dismissed", score=0.0)
|
||
src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "X", 0.9)]})
|
||
run_discovery(conn, [src], FakeMbBrowser(), DiscoveryConfig())
|
||
with conn.cursor() as cur:
|
||
cur.execute('SELECT status::text, score FROM "DiscoverySuggestion" WHERE "artistMbid" = %s', ("c1",))
|
||
assert cur.fetchone() == ("dismissed", 0.0) # untouched — WHERE status='pending' blocked the update
|
||
|
||
|
||
def test_stamps_last_discovered_and_throttles(conn):
|
||
insert_watched_artist(conn, mbid="s1", name="Seed")
|
||
src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "X", 0.9)]})
|
||
assert run_discovery(conn, [src], FakeMbBrowser(), DiscoveryConfig()).artists == 1
|
||
with conn.cursor() as cur:
|
||
cur.execute('SELECT "lastDiscoveredAt" IS NOT NULL FROM "WatchedArtist" WHERE mbid = %s', ("s1",))
|
||
assert cur.fetchone()[0] is True
|
||
# second run: seed not due (lastDiscoveredAt recent) -> no seeds -> nothing new
|
||
assert run_discovery(conn, [src], FakeMbBrowser(), DiscoveryConfig()).artists == 0
|
||
|
||
|
||
def test_unhealthy_source_contributes_nothing(conn):
|
||
insert_watched_artist(conn, mbid="s1", name="Seed")
|
||
src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "X", 0.9)]}, healthy=False)
|
||
assert run_discovery(conn, [src], FakeMbBrowser(), DiscoveryConfig()).artists == 0
|
||
assert _artist_rows(conn) == []
|
||
|
||
|
||
def test_source_with_raising_health_is_skipped_not_fatal(conn):
|
||
insert_watched_artist(conn, mbid="s1", name="Seed")
|
||
|
||
class BoomSource:
|
||
name = "boom"
|
||
def health(self):
|
||
raise RuntimeError("boom")
|
||
def similar_artists(self, mbid):
|
||
return []
|
||
|
||
good = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "X", 0.9)]})
|
||
result = run_discovery(conn, [BoomSource(), good], FakeMbBrowser(), DiscoveryConfig())
|
||
assert result.artists == 1 # good source still processed; boom skipped, no crash
|
||
|
||
|
||
def test_config_from_config_parses_and_defaults():
|
||
cfg = DiscoveryConfig.from_config({"discover.enabled": "true", "discover.chunkSize": "10",
|
||
"discover.minScore": "0.3"})
|
||
assert cfg.enabled is True and cfg.chunk_size == 10 and cfg.min_score == 0.3
|
||
d = DiscoveryConfig.from_config({})
|
||
assert (d.enabled, d.interval_hours, d.chunk_size, d.similar_per_seed,
|
||
d.albums_per_artist, d.min_score) == (False, 168, 5, 20, 1, 0.0)
|
||
|
||
|
||
def _artist_score(conn, mbid):
|
||
with conn.cursor() as cur:
|
||
cur.execute('SELECT score, "seedCount" FROM "DiscoverySuggestion" WHERE "artistMbid" = %s', (mbid,))
|
||
return cur.fetchone()
|
||
|
||
|
||
def test_score_sums_across_separate_chunks(conn):
|
||
# Two seeds, both eligible, forced into separate one-seed chunks by chunk_size=1.
|
||
insert_watched_artist(conn, mbid="s1", name="Seed One",
|
||
last_discovered_sql="now() - interval '200 hours'")
|
||
insert_watched_artist(conn, mbid="s2", name="Seed Two",
|
||
last_discovered_sql="now() - interval '190 hours'")
|
||
src = FakeSimilaritySource(similar={
|
||
"s1": [SimilarArtist("c1", "Shared", 0.6)],
|
||
"s2": [SimilarArtist("c1", "Shared", 0.5)],
|
||
})
|
||
cfg = DiscoveryConfig(chunk_size=1)
|
||
run_discovery(conn, [src], FakeMbBrowser(), cfg) # processes s1 (older)
|
||
run_discovery(conn, [src], FakeMbBrowser(), cfg) # processes s2
|
||
# replace-not-accumulate would leave 0.5/seedCount1; contributions give the sum.
|
||
assert _artist_score(conn, "c1") == (2.0, 2) # each seed max-normalizes to 1.0
|
||
|
||
|
||
def test_partial_sweep_preserves_other_seeds_contribution(conn):
|
||
# Full sweep: c1 gets s1(0.6)+s2(0.5)=1.1. Then only s1 is due again with a new
|
||
# score; s2's contribution must survive untouched.
|
||
insert_watched_artist(conn, mbid="s1", name="Seed One",
|
||
last_discovered_sql="now() - interval '200 hours'")
|
||
insert_watched_artist(conn, mbid="s2", name="Seed Two",
|
||
last_discovered_sql="now() - interval '200 hours'")
|
||
src = FakeSimilaritySource(similar={
|
||
"s1": [SimilarArtist("c1", "Shared", 0.6)],
|
||
"s2": [SimilarArtist("c1", "Shared", 0.5)],
|
||
})
|
||
run_discovery(conn, [src], FakeMbBrowser(), DiscoveryConfig()) # both -> 1.1/2
|
||
assert _artist_score(conn, "c1") == (2.0, 2) # each seed max-normalizes to 1.0
|
||
|
||
# s1 due again (older than interval), s2 fresh; s1 now scores c1 at 0.7.
|
||
with conn.cursor() as cur:
|
||
cur.execute('UPDATE "WatchedArtist" SET "lastDiscoveredAt" = now() - interval \'200 hours\' WHERE mbid = %s', ("s1",))
|
||
cur.execute('UPDATE "WatchedArtist" SET "lastDiscoveredAt" = now() WHERE mbid = %s', ("s2",))
|
||
conn.commit()
|
||
src2 = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "Shared", 0.7)]})
|
||
run_discovery(conn, [src2], FakeMbBrowser(), DiscoveryConfig())
|
||
assert _artist_score(conn, "c1") == (2.0, 2) # 1.0 (new s1) + 1.0 (kept s2), max-normalized
|
||
|
||
|
||
def test_seed_dropoff_lowers_seedcount(conn):
|
||
insert_watched_artist(conn, mbid="s1", name="Seed One",
|
||
last_discovered_sql="now() - interval '200 hours'")
|
||
insert_watched_artist(conn, mbid="s2", name="Seed Two",
|
||
last_discovered_sql="now() - interval '200 hours'")
|
||
src = FakeSimilaritySource(similar={
|
||
"s1": [SimilarArtist("c1", "Shared", 0.6)],
|
||
"s2": [SimilarArtist("c1", "Shared", 0.5)],
|
||
})
|
||
run_discovery(conn, [src], FakeMbBrowser(), DiscoveryConfig())
|
||
assert _artist_score(conn, "c1") == (2.0, 2) # each seed max-normalizes to 1.0
|
||
|
||
# s1 re-swept but no longer finds c1 -> its contribution is removed, c1 recomputed.
|
||
with conn.cursor() as cur:
|
||
cur.execute('UPDATE "WatchedArtist" SET "lastDiscoveredAt" = now() - interval \'200 hours\' WHERE mbid = %s', ("s1",))
|
||
cur.execute('UPDATE "WatchedArtist" SET "lastDiscoveredAt" = now() WHERE mbid = %s', ("s2",))
|
||
conn.commit()
|
||
src2 = FakeSimilaritySource(similar={"s1": [SimilarArtist("c9", "Other", 0.9)]})
|
||
run_discovery(conn, [src2], FakeMbBrowser(), DiscoveryConfig())
|
||
assert _artist_score(conn, "c1") == (1.0, 1) # only s2 remains (max-normalized to 1.0)
|
||
|
||
|
||
def test_evicts_contributions_from_removed_seed(conn):
|
||
# Two live seeds contribute to c1 -> 1.1/2. Then s1 is unfollowed (deleted from
|
||
# WatchedArtist, simulating DELETE /api/artists/[id]) without any FK cascade on
|
||
# DiscoverySeedContribution. The next sweep must evict s1's orphaned contribution
|
||
# rather than leaving it to inflate c1's score forever.
|
||
insert_watched_artist(conn, mbid="s1", name="Seed One",
|
||
last_discovered_sql="now() - interval '200 hours'")
|
||
insert_watched_artist(conn, mbid="s2", name="Seed Two",
|
||
last_discovered_sql="now() - interval '200 hours'")
|
||
src = FakeSimilaritySource(similar={
|
||
"s1": [SimilarArtist("c1", "Shared", 0.6)],
|
||
"s2": [SimilarArtist("c1", "Shared", 0.5)],
|
||
})
|
||
run_discovery(conn, [src], FakeMbBrowser(), DiscoveryConfig())
|
||
assert _artist_score(conn, "c1") == (2.0, 2) # each seed max-normalizes to 1.0
|
||
|
||
# Unfollow s1 (no FK cascade on DiscoverySeedContribution).
|
||
with conn.cursor() as cur:
|
||
cur.execute('DELETE FROM "WatchedArtist" WHERE mbid = %s', ("s1",))
|
||
conn.commit()
|
||
|
||
# Make s2 due again and sweep; s1 can no longer be a seed since it's gone.
|
||
with conn.cursor() as cur:
|
||
cur.execute('UPDATE "WatchedArtist" SET "lastDiscoveredAt" = now() - interval \'200 hours\' WHERE mbid = %s', ("s2",))
|
||
conn.commit()
|
||
src2 = FakeSimilaritySource(similar={"s2": [SimilarArtist("c1", "Shared", 0.5)]})
|
||
run_discovery(conn, [src2], FakeMbBrowser(), DiscoveryConfig())
|
||
|
||
assert _artist_score(conn, "c1") == (1.0, 1) # s1's stale contribution evicted; s2 alone -> 1.0
|
||
with conn.cursor() as cur:
|
||
cur.execute('SELECT count(*) FROM "DiscoverySeedContribution" WHERE "seedMbid" = %s', ("s1",))
|
||
assert cur.fetchone()[0] == 0
|
||
|
||
|
||
def test_run_discovery_processes_at_most_chunk_size_seeds(conn):
|
||
from lyra_worker.discovery import DiscoveryConfig, run_discovery
|
||
from lyra_worker.adapters.fakes import FakeMbBrowser, FakeSimilaritySource
|
||
from lyra_worker.similarity.base import SimilarArtist
|
||
from tests.conftest import insert_watched_artist
|
||
for i in range(4):
|
||
insert_watched_artist(conn, mbid=f"s{i}", name=f"Seed{i}")
|
||
src = FakeSimilaritySource(similar={f"s{i}": [SimilarArtist(f"c{i}", f"Cand{i}", 0.9)] for i in range(4)})
|
||
cfg = DiscoveryConfig.from_config({"discover.chunkSize": "2"})
|
||
result = run_discovery(conn, [src], FakeMbBrowser(), cfg)
|
||
assert result.seeds == 2 # only chunk_size seeds this call
|
||
# exactly 2 seeds now have lastDiscoveredAt set
|
||
with conn.cursor() as cur:
|
||
cur.execute('SELECT count(*) FROM "WatchedArtist" WHERE "lastDiscoveredAt" IS NOT NULL')
|
||
assert cur.fetchone()[0] == 2
|