File size: 3,970 Bytes
46c2d7b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
"""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"