Files
Lyra/worker/tests/test_discovery.py
T
Jonathan 1943fe2bdf feat(discover): max-normalize similarity scores across sources
_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>
2026-07-14 21:43:02 +02:00

238 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 01 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