Cinematch / tests /test_rerank.py
Amit-kr26's picture
feat: MLOps eval, A/B testing, DLQ, WebSocket, cold-start fallback
3fc8ccc
Raw
History Blame Contribute Delete
12.1 kB
# tests/test_rerank.py
import numpy as np
def make_item_factors(movie_ids: list[int], rank: int = 4, seed: int = 7) -> dict[int, np.ndarray]:
rng = np.random.default_rng(seed)
return {mid: rng.random(rank).astype(np.float32) for mid in movie_ids}
def make_candidates(movie_ids: list[int]) -> list[dict]:
return [{"movie_id": mid, "title": f"Movie {mid}", "genres": "Action",
"als_score": 1.0, "score": 1.0} for mid in movie_ids]
def test_compute_preference_vector_time_decay():
"""Recent events get higher weight than old events."""
from streaming import compute_preference_vector
item_factors = make_item_factors([1, 2, 3])
now_ts = 1_000_000.0
events = [
{"movie_id": 1, "ts": now_ts - 10}, # recent
{"movie_id": 2, "ts": now_ts - 10_000}, # old
]
pref = compute_preference_vector(events, item_factors, now_ts, lambda_decay=0.001)
# Normalise factors for comparison
f1 = item_factors[1] / np.linalg.norm(item_factors[1])
f2 = item_factors[2] / np.linalg.norm(item_factors[2])
sim1 = float(np.dot(pref, f1))
sim2 = float(np.dot(pref, f2))
assert sim1 > sim2, f"Recent event should dominate: sim1={sim1:.3f}, sim2={sim2:.3f}"
def test_rerank_uses_preference_vector():
"""After interacting with movie 5, similar movie 6 should rank first."""
from streaming import compute_preference_vector, rerank_candidates
rng = np.random.default_rng(42)
base = rng.random(4).astype(np.float32)
item_factors = {
5: base + 0.01,
6: base + 0.02, # similar to 5
99: rng.random(4).astype(np.float32), # unrelated
100: rng.random(4).astype(np.float32), # unrelated
}
now_ts = 1_000_000.0
events = [{"movie_id": 5, "ts": now_ts - 1}]
pref = compute_preference_vector(events, item_factors, now_ts)
candidates = make_candidates([6, 99, 100])
ranked = rerank_candidates(pref, candidates, item_factors)
assert ranked[0]["movie_id"] == 6, \
f"Movie similar to interacted item should rank first, got {ranked[0]['movie_id']}"
def test_rerank_returns_top_n():
"""rerank_candidates returns exactly top_n items."""
from streaming import compute_preference_vector, rerank_candidates
item_factors = make_item_factors(list(range(1, 51)))
now_ts = 1_000_000.0
events = [{"movie_id": 1, "ts": now_ts - 5}]
pref = compute_preference_vector(events, item_factors, now_ts)
candidates = make_candidates(list(range(1, 51)))
ranked = rerank_candidates(pref, candidates, item_factors, top_n=10)
assert len(ranked) == 10
def test_merge_events_fifo_capped():
"""merge_events keeps last max_events events in FIFO order."""
from streaming import merge_events
existing = [{"movie_id": i, "ts": float(i)} for i in range(45)]
new = [{"movie_id": 100 + i, "ts": float(100 + i)} for i in range(10)]
merged = merge_events(existing, new, max_events=50)
assert len(merged) == 50
assert merged[0]["movie_id"] == 5 # first 5 of existing dropped
assert merged[-1]["movie_id"] == 109
def test_preference_vector_zero_events():
"""No events β†’ zero preference vector of correct rank."""
from streaming import compute_preference_vector
item_factors = make_item_factors([1, 2, 3], rank=4)
pref = compute_preference_vector([], item_factors, 1_000_000.0)
assert pref.shape == (4,)
assert np.allclose(pref, 0.0)
def test_rerank_scores_descending():
"""rerank_candidates returns items in descending score order."""
from streaming import compute_preference_vector, rerank_candidates
item_factors = make_item_factors(list(range(1, 11)))
now_ts = 1_000_000.0
events = [{"movie_id": 1, "ts": now_ts - 1}]
pref = compute_preference_vector(events, item_factors, now_ts)
candidates = make_candidates(list(range(1, 11)))
ranked = rerank_candidates(pref, candidates, item_factors, top_n=10)
scores = [r["score"] for r in ranked]
assert scores == sorted(scores, reverse=True)
def test_preference_vector_empty_item_factors():
"""Empty item_factors β†’ zero vector of rank 0 (not hardcoded 50)."""
from streaming import compute_preference_vector
events = [{"movie_id": 1, "ts": 1_000_000.0}]
pref = compute_preference_vector(events, {}, 1_000_000.0)
assert pref.shape == (0,)
assert np.allclose(pref, 0.0)
def test_rerank_als_score_fallback():
"""When movie_id not in item_factors, falls back to als_score for ranking."""
from streaming import rerank_candidates
pref = np.array([1.0, 0.0, 0.0, 0.0], dtype=np.float32)
item_factors = {} # empty β€” no factors available
candidates = [
{"movie_id": 1, "title": "A", "genres": "X", "als_score": 3.0, "score": 3.0},
{"movie_id": 2, "title": "B", "genres": "Y", "als_score": 5.0, "score": 5.0},
{"movie_id": 3, "title": "C", "genres": "Z", "als_score": 1.0, "score": 1.0},
]
ranked = rerank_candidates(pref, candidates, item_factors)
# Should be ordered by als_score descending: 2, 1, 3
assert ranked[0]["movie_id"] == 2
assert ranked[1]["movie_id"] == 1
assert ranked[2]["movie_id"] == 3
# ── Event-type weighting tests ───────────────────────────────────────────────
def test_rating_outweighs_click():
"""A rated movie should pull preference vector more than a clicked movie."""
from streaming import compute_preference_vector
item_factors = make_item_factors([10, 20], rank=4, seed=99)
now_ts = 1_000_000.0
events = [
{"movie_id": 10, "ts": now_ts - 5, "event_type": "rating", "rating": 5.0},
{"movie_id": 20, "ts": now_ts - 5, "event_type": "click"},
]
pref = compute_preference_vector(events, item_factors, now_ts)
f10 = item_factors[10] / np.linalg.norm(item_factors[10])
f20 = item_factors[20] / np.linalg.norm(item_factors[20])
assert np.dot(pref, f10) > np.dot(pref, f20), \
"5-star rating (weight=3.0) should dominate over click (weight=1.0)"
def test_any_rating_outweighs_view():
"""Even a 1-star rating should pull harder than a view (explicit > implicit)."""
from streaming import compute_preference_vector
item_factors = {
1: np.array([1.0, 0.0, 0.0, 0.0], dtype=np.float32),
2: np.array([0.0, 1.0, 0.0, 0.0], dtype=np.float32),
}
now_ts = 1_000_000.0
events = [
{"movie_id": 1, "ts": now_ts - 5, "event_type": "rating", "rating": 1.0},
{"movie_id": 2, "ts": now_ts - 5, "event_type": "view"},
]
pref = compute_preference_vector(events, item_factors, now_ts)
# 1-star effective weight = 3.0 * (0.4 + 0.2*0.6) = 1.56 > view weight 1.5
assert np.dot(pref, item_factors[1]) > np.dot(pref, item_factors[2]), \
"1-star explicit rating (1.56) should outweigh view (1.5)"
def test_view_outweighs_click():
"""A viewed movie should have stronger influence than a clicked movie."""
from streaming import compute_preference_vector
item_factors = make_item_factors([30, 40], rank=4, seed=77)
now_ts = 1_000_000.0
events = [
{"movie_id": 30, "ts": now_ts - 5, "event_type": "view"},
{"movie_id": 40, "ts": now_ts - 5, "event_type": "click"},
]
pref = compute_preference_vector(events, item_factors, now_ts)
f30 = item_factors[30] / np.linalg.norm(item_factors[30])
f40 = item_factors[40] / np.linalg.norm(item_factors[40])
assert np.dot(pref, f30) > np.dot(pref, f40), \
"View (weight=1.5) should dominate over click (weight=1.0)"
def test_high_star_rating_has_greater_raw_weight_than_low_star():
"""A 5-star rating contributes 5Γ— more raw weight than a 1-star rating."""
from streaming import compute_preference_vector
# Use two orthogonal basis vectors so dot products are independent
item_factors = {
1: np.array([1.0, 0.0, 0.0, 0.0], dtype=np.float32),
2: np.array([0.0, 1.0, 0.0, 0.0], dtype=np.float32),
}
now_ts = 1_000_000.0
events = [
{"movie_id": 1, "ts": now_ts - 1, "event_type": "rating", "rating": 5.0},
{"movie_id": 2, "ts": now_ts - 1, "event_type": "rating", "rating": 1.0},
]
pref = compute_preference_vector(events, item_factors, now_ts)
# 5-star weight = 3.0 * (0.4 + 1.0*0.6) = 3.0; 1-star weight = 3.0 * (0.4 + 0.2*0.6) = 1.56
# Dot product with each basis vector reflects raw weight ratio
assert np.dot(pref, item_factors[1]) > np.dot(pref, item_factors[2]), \
"5-star rating should pull preference vector more than 1-star"
# ── Genre diversity tests ─────────────────────────────────────────────────────
def test_genre_diversity_soft_penalty():
"""Soft penalty: items beyond max_per_genre threshold get exponentially lower scores."""
from streaming import rerank_candidates
# All candidates share one genre β€” penalty should push score down progressively
candidates = [
{"movie_id": i, "title": f"M{i}", "genres": "Action",
"als_score": float(i) / 10, "score": float(i) / 10}
for i in range(1, 11)
]
pref = np.zeros(4, dtype=np.float32)
ranked = rerank_candidates(pref, candidates, {}, max_per_genre=2, top_n=10)
# All 10 items are Action; first 2 should be un-penalized, 3rd+ should have lower score
# Verify the 3rd item has a lower score than if it were unpenalized
assert ranked[0]["score"] > ranked[2]["score"], "3rd genre occurrence should be penalized"
# All 10 items still returned (soft cap, not hard exclusion)
assert len(ranked) == 10, "Soft penalty should not hard-exclude items"
def test_genre_hybrid_boosts_preferred_genre():
"""User genre prefs should boost candidates matching preferred genres."""
from streaming import compute_genre_preferences, rerank_candidates
candidates = [
{"movie_id": 1, "title": "A", "genres": "Action", "als_score": 1.0, "score": 1.0},
{"movie_id": 2, "title": "B", "genres": "Drama", "als_score": 1.0, "score": 1.0},
]
events = [{"movie_id": 1, "ts": 1_000_000.0, "event_type": "view"}]
movie_meta = {1: {"genres": "Action"}, 2: {"genres": "Drama"}}
genre_prefs = compute_genre_preferences(events, movie_meta)
pref = np.zeros(4, dtype=np.float32)
ranked = rerank_candidates(pref, candidates, {}, user_genre_prefs=genre_prefs,
max_per_genre=10, top_n=2)
assert ranked[0]["movie_id"] == 1, "Action candidate should rank first for Action-biased user"
def test_compute_genre_preferences_type_weighted():
"""Rating events should contribute 3Γ— more to genre prefs than click events."""
from streaming import compute_genre_preferences
events = [
{"movie_id": 1, "event_type": "rating"},
{"movie_id": 2, "event_type": "click"},
]
movie_meta = {1: {"genres": "Action"}, 2: {"genres": "Drama"}}
prefs = compute_genre_preferences(events, movie_meta)
assert prefs.get("Action", 0) > prefs.get("Drama", 0), \
"Rating (3Γ—) should give Action higher preference weight than Drama from click (1Γ—)"
# ── DLQ validation tests ──────────────────────────────────────────────────────
def test_validate_event_valid():
from streaming import _validate_event
valid, reason = _validate_event(1, 42, "click")
assert valid and reason is None
def test_validate_event_invalid_user():
from streaming import _validate_event
valid, reason = _validate_event(0, 42, "click")
assert not valid and reason == "invalid_user_id"
def test_validate_event_invalid_type():
from streaming import _validate_event
valid, reason = _validate_event(1, 42, "unknown")
assert not valid and reason is not None and "unknown_event_type" in reason