ci / api / api (push) Canceled after 0s
ci / module boundaries / boundaries (push) Canceled after 0s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Canceled after 11m29s
ci / db / db (push) Canceled after 11m22s
ci / mobile / mobile (Metro-бандл Android) (push) Canceled after 6m43s
ci / recommender / recommender (push) Canceled after 6m47s
ci / ts / ts (packages + web) (push) Canceled after 1s
160 lines
5.9 KiB
Python
160 lines
5.9 KiB
Python
import numpy as np
|
|
import pytest
|
|
|
|
from pipu_rec.wave.contexts import load_contexts
|
|
from pipu_rec.wave.engine import CatalogIndex, Profile, Session, build_batch, taste_centroids, update_session
|
|
|
|
|
|
def library(n_per_cluster: int = 20, seed: int = 0) -> CatalogIndex:
|
|
"""Три «жанра» — кластера эмбеддингов; в кластере 5 исполнителей по 4 трека."""
|
|
rng = np.random.default_rng(seed)
|
|
centers = rng.normal(size=(3, 32))
|
|
ids, artists, embs = [], [], []
|
|
feats: dict[str, list[float | None]] = {
|
|
"bpm": [],
|
|
"energy": [],
|
|
"danceability": [],
|
|
"valence": [],
|
|
"acousticness": [],
|
|
}
|
|
for c in range(3):
|
|
for k in range(n_per_cluster):
|
|
ids.append(f"t{c}-{k}")
|
|
artists.append(f"a{c}-{k // 4}")
|
|
embs.append((centers[c] + 0.3 * rng.normal(size=32)).astype(np.float32))
|
|
feats["bpm"].append([90.0, 128.0, 172.0][c])
|
|
feats["energy"].append([0.2, 0.5, 0.85][c])
|
|
feats["danceability"].append(0.7)
|
|
feats["valence"].append(0.5)
|
|
feats["acousticness"].append(None)
|
|
return CatalogIndex.build(ids, artists, embs, feats, plays=[1] * len(ids), dim=32)
|
|
|
|
|
|
def profile(**kw: object) -> Profile:
|
|
base = dict(
|
|
scores={},
|
|
disliked=set(),
|
|
disliked_artists=set(),
|
|
recent=set(),
|
|
played=set(),
|
|
liked_recent=set(),
|
|
rediscover=set(),
|
|
exploration=0.0,
|
|
)
|
|
base.update(kw)
|
|
return Profile(**base) # type: ignore[arg-type]
|
|
|
|
|
|
def test_friend_signal_is_bounded_and_respects_negative_feedback() -> None:
|
|
index = library()
|
|
blocked = {"t0-0", "t1-0"}
|
|
picks = build_batch(index, profile(disliked=blocked), Session(), [], n=5,
|
|
friend_ids=index.ids, rng=np.random.default_rng(8))
|
|
assert not blocked.intersection(p.id for p in picks)
|
|
assert sum(p.source == "friends" for p in picks) <= 1
|
|
assert len(picks) == 5
|
|
|
|
|
|
def test_empty_friend_signal_does_not_change_ranking() -> None:
|
|
index = library()
|
|
first = build_batch(index, profile(), Session(), [], rng=np.random.default_rng(17))
|
|
second = build_batch(index, profile(), Session(), [], friend_ids=[], rng=np.random.default_rng(17))
|
|
assert [p.id for p in first] == [p.id for p in second]
|
|
|
|
|
|
def cluster(track_id: str) -> int:
|
|
return int(track_id[1])
|
|
|
|
|
|
def test_cold_start_returns_a_diverse_batch() -> None:
|
|
idx = library()
|
|
picks = build_batch(idx, profile(), Session(), [], n=5, rng=np.random.default_rng(1))
|
|
assert len(picks) == 5
|
|
assert len({p.id for p in picks}) == 5
|
|
assert len({idx.artist[idx.pos[p.id]] for p in picks}) == 5, "one track per artist in a batch"
|
|
|
|
|
|
def test_taste_pulls_towards_liked_cluster() -> None:
|
|
idx = library()
|
|
liked = {f"t2-{k}": 3.0 for k in range(16)}
|
|
picks = build_batch(
|
|
idx, profile(scores=liked, played=set(liked)), Session(), [], n=5, rng=np.random.default_rng(2)
|
|
)
|
|
assert sum(cluster(p.id) == 2 for p in picks) >= 4
|
|
|
|
|
|
def test_dislikes_recent_and_served_are_excluded() -> None:
|
|
idx = library(5)
|
|
p = profile(disliked={"t0-0"}, disliked_artists={"a1-0"}, recent={"t2-0"})
|
|
s = Session(served=["t2-1"])
|
|
for seed in range(5):
|
|
ids = {x.id for x in build_batch(idx, p, s, [], n=5, rng=np.random.default_rng(seed))}
|
|
assert not ids & {"t0-0", "t2-0", "t2-1"}
|
|
assert not any(idx.artist[idx.pos[i]] == "a1-0" for i in ids), "disliked artist"
|
|
|
|
|
|
def test_running_context_prefers_fast_energetic_tracks() -> None:
|
|
idx = library()
|
|
running = load_contexts().activities["running"].rules
|
|
picks = build_batch(idx, profile(), Session(), running, n=5, rng=np.random.default_rng(3))
|
|
assert all(cluster(p.id) == 2 for p in picks), [p.id for p in picks]
|
|
|
|
|
|
def test_half_time_counts_for_running() -> None:
|
|
from pipu_rec.wave.contexts import fit
|
|
|
|
running = load_contexts().activities["running"].rules
|
|
f = fit(
|
|
{
|
|
"bpm": np.array([85.0, 120.0]),
|
|
"energy": np.array([0.8, 0.8]),
|
|
"danceability": np.array([0.7, 0.7]),
|
|
"valence": np.array([np.nan, np.nan]),
|
|
"acousticness": np.array([np.nan, np.nan]),
|
|
},
|
|
running,
|
|
)
|
|
assert f[0] > 0.9 > f[1]
|
|
|
|
|
|
def test_session_vector_follows_reactions_and_changes_course() -> None:
|
|
idx = library()
|
|
liked = {f"t0-{k}": 3.0 for k in range(10)} | {f"t2-{k}": 3.0 for k in range(10)}
|
|
cents = taste_centroids(idx, profile(scores=liked))
|
|
s = Session()
|
|
update_session(idx, s, [("t1-0", "like"), ("t1-1", "complete")], cents)
|
|
assert s.vec is not None
|
|
without = build_batch(idx, profile(scores=liked), Session(), [], n=5, rng=np.random.default_rng(4))
|
|
picks = build_batch(idx, profile(scores=liked), s, [], n=5, rng=np.random.default_rng(4))
|
|
assert sum(cluster(p.id) == 1 for p in picks) > sum(cluster(p.id) == 1 for p in without)
|
|
assert sum(cluster(p.id) == 1 for p in picks) >= 2, "the session pulls the wave towards its reactions"
|
|
|
|
update_session(idx, s, [("t1-2", "skip_early"), ("t1-3", "skip_early"), ("t1-4", "dislike")], cents)
|
|
assert s.skips_in_row == 0, "course changed after three skips"
|
|
vec = np.array(s.vec)
|
|
assert float(vec @ idx.emb[idx.pos["t1-0"]]) < 0.5
|
|
|
|
|
|
def test_exploration_brings_unheard_tracks() -> None:
|
|
idx = library()
|
|
heard = {f"t2-{k}" for k in range(20)}
|
|
liked = {t: 3.0 for t in heard}
|
|
picks = build_batch(
|
|
idx,
|
|
profile(scores=liked, played=heard, exploration=0.4),
|
|
Session(),
|
|
[],
|
|
n=5,
|
|
rng=np.random.default_rng(5),
|
|
)
|
|
assert sum(p.source == "fresh" for p in picks) >= 2
|
|
|
|
|
|
def test_bad_context_file_is_rejected(tmp_path) -> None: # type: ignore[no-untyped-def]
|
|
bad = tmp_path / "c.yaml"
|
|
bad.write_text(
|
|
"activities: {x: {icon: a, rules: [{feature: tempo, min: 1, sigma: 1}]}}\nmoods: {}\n", "utf-8"
|
|
)
|
|
with pytest.raises(ValueError):
|
|
load_contexts(str(bad))
|