ResearchIT / tests /test_cold_start_fallback.py
siddhm11
feat(feed): improve multi-interest serving and measurement
46c2d7b
Raw History Blame Contribute Delete
3.97 kB
"""A reader who skips onboarding must still get a feed.
Tier 0 was gated on the reader having picked categories, with no else branch,
so skipping onboarding fell through the whole cascade to "Nothing here yet" --
permanently, unless they independently found search and saved something. 68 of
the first 120 users saved exactly once, so the empty-state path is not a corner
case, and CLAUDE.md §3.6 recorded this fallback as already done.
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
from app import db, turso_svc
from app.config import DEFAULT_TRENDING_CATEGORIES
from app.routers import recommendations as R
class _NoSaves:
positive_list: list[str] = []
negative_list: list[str] = []
def has_enough_for_recs(self) -> bool:
return False
@pytest.fixture
def feed_db(tmp_path, monkeypatch):
monkeypatch.setattr(db, "DB_PATH", str(tmp_path / "cold.db"))
return db.DB_PATH
def _trending_spy(n=12):
seen: dict = {}
async def fake(categories, limit=10):
seen["categories"] = set(categories)
seen["limit"] = limit
return [{"arxiv_id": f"2401.{i:05d}"} for i in range(n)]
return fake, seen
async def _build(user_id="u-skip", categories=None):
await db.init_db()
if categories:
await db.save_onboarding_categories(user_id, list(categories))
fake, seen = _trending_spy()
with patch.object(turso_svc, "fetch_trending_by_categories", side_effect=fake):
entry = await R._build_feed(user_id, _NoSaves(), "q-cold")
return entry, seen
async def test_skipping_onboarding_still_produces_a_feed(feed_db):
"""The regression. This used to return None."""
entry, _ = await _build()
assert entry is not None, "a reader who skipped onboarding got nothing"
assert entry["ranked"], "the fallback produced an empty ranking"
assert entry["trending"] is True
async def test_the_default_feed_spans_more_than_machine_learning(feed_db):
"""Someone who told us nothing should not be shown a CS-only site."""
_, seen = await _build()
assert seen["categories"] == set(DEFAULT_TRENDING_CATEGORIES)
prefixes = {c.split(".")[0] for c in seen["categories"]}
assert len(prefixes) >= 4, f"too narrow a default: {prefixes}"
async def test_the_two_cold_start_feeds_are_distinguishable_in_the_log(feed_db):
""""Your categories" and "we had nothing to go on" are different feeds and
must not share a source tag, or the exposure log cannot tell them apart."""
skipped, _ = await _build(user_id="u-skip")
picked, _ = await _build(user_id="u-picked", categories=["ml"])
def source_of(entry):
return next(iter(entry["tags"].values()))["candidate_source"]
assert source_of(skipped) == "trending_default_fallback"
assert source_of(picked) == "trending_category_fallback"
async def test_a_reader_who_picked_categories_still_gets_only_those(feed_db):
"""The fallback must not widen a deliberate choice."""
_, seen = await _build(user_id="u-picked", categories=["ml"])
assert seen["categories"] == {"cs.LG", "stat.ML"} # the "ml" group
async def test_both_cold_start_feeds_report_tier_zero(feed_db):
"""_serving_tier drives the progress UI and the exposure log's tier column;
a source tag it does not know silently reports Tier 1."""
skipped, _ = await _build(user_id="u-skip")
picked, _ = await _build(user_id="u-picked", categories=["ml"])
assert R._serving_tier(skipped) == 0
assert R._serving_tier(picked) == 0
async def test_the_fallback_still_carries_real_propensities(feed_db):
"""Tier 0 fills slots epsilon-greedily, and §3.4b forbids logging a
degenerate 1.0 on a tier that has randomness."""
entry, _ = await _build()
props = [t["propensity"] for t in entry["tags"].values()]
assert props and all(0.0 < p <= 1.0 for p in props)
assert any(p < 1.0 for p in props), "no exploration propensity recorded"