File size: 7,646 Bytes
c6230f6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
"""Tests for pattern_mining: association rules over watch history.

The headline regression here is the channel-eligibility gate. It used to be
max(10, len(watch_events)//100), which scales with total watches -- so a user with
20,626 watches needed 206 on a single channel to be considered, and got zero patterns
while a user with 1,000 watches got plenty. Heavier use produced fewer insights.
"""
import collections
import os
import sys
from datetime import datetime, timedelta

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))

from services.pattern_mining import (  # noqa: E402
    MIN_SUPPORT,
    channel_day_rules,
    channel_slot_rules,
    cooccurrence_rules,
    find_patterns,
    opener_rules,
    seasonal_rules,
    top_channels,
)

BASE = datetime(2026, 3, 1, 9, 0)  # a Sunday


def watch(channel, when):
    return {
        "type": "watch",
        "channel_clean": channel,
        "timestamp_local": when.isoformat(),
        "hour_local": when.hour,
        "day_of_week": when.weekday(),
        "month_local": when.month,
        "text_clean": f"{channel} video",
    }


def spread(channel, count, start=BASE, step_days=7, hour=None):
    """`count` watches of one channel, one per step_days, optionally pinned to an hour."""
    out = []
    for i in range(count):
        when = start + timedelta(days=i * step_days)
        if hour is not None:
            when = when.replace(hour=hour)
        out.append(watch(channel, when))
    return out


# --- channel eligibility ----------------------------------------------------------

def test_gate_is_an_absolute_floor_not_a_proportion():
    """The regression: a heavy watcher must not be gated out of their own patterns."""
    events = spread("habit", 40) + [
        watch(f"noise{i}", BASE + timedelta(hours=i)) for i in range(20000)
    ]

    assert "habit" in top_channels(events)


def test_channels_below_the_floor_are_excluded():
    events = spread("rare", MIN_SUPPORT - 1) + spread("common", MIN_SUPPORT + 5)

    selected = top_channels(events)

    assert "common" in selected
    assert "rare" not in selected


def test_no_channels_yields_empty_selection():
    assert top_channels([]) == set()


# --- individual rule types --------------------------------------------------------

def test_finds_a_day_of_week_habit():
    """Every watch on a Sunday: confidence 1.0, well above the 1/7 baseline."""
    events = spread("sunday show", 20) + spread("filler", 20, step_days=1)

    rules = channel_day_rules(events, {"sunday show", "filler"})

    sunday = [r for r in rules if r["channel"] == "sunday show"]
    assert sunday, "expected a Sunday pattern"
    assert sunday[0]["day"] == "Sunday"
    assert sunday[0]["confidence"] == 1.0


def test_finds_a_time_of_day_habit():
    events = spread("morning show", 20, step_days=1, hour=7) + spread(
        "evening show", 20, step_days=1, hour=20
    )

    rules = channel_slot_rules(events, {"morning show", "evening show"})

    morning = [r for r in rules if r["channel"] == "morning show"]
    assert morning and morning[0]["slot"] == "morning"


def test_finds_channels_watched_together_in_a_session():
    """Two channels always in the same sitting -- the association the README promises."""
    events = []
    for i in range(20):
        start = BASE + timedelta(days=i)
        events.append(watch("artist a", start))
        events.append(watch("artist b", start + timedelta(minutes=5)))
    # Enough unrelated sessions that the pair's base rate is low -- with only a handful
    # of sessions, two channels in half of them is barely above chance by construction.
    for i in range(180):
        events.append(watch(f"other {i}", BASE + timedelta(days=100 + i)))

    rules = cooccurrence_rules(events)

    pairs = {frozenset((r["channel_a"], r["channel_b"])) for r in rules}
    assert frozenset(("artist a", "artist b")) in pairs


def test_cooccurrence_ignores_channels_that_never_share_a_session():
    events = []
    for i in range(20):
        events.append(watch("solo a", BASE + timedelta(days=i)))
        events.append(watch("solo b", BASE + timedelta(days=i, hours=6)))

    rules = cooccurrence_rules(events)

    assert not [
        r for r in rules
        if {r["channel_a"], r["channel_b"]} == {"solo a", "solo b"}
    ]


def test_finds_a_session_opening_channel():
    events = []
    for i in range(20):
        start = BASE + timedelta(days=i)
        events.append(watch("opener", start))
        events.append(watch("follower", start + timedelta(minutes=10)))

    rules = opener_rules(events)

    assert rules and rules[0]["channel"] == "opener"


# --- orchestration ----------------------------------------------------------------

def test_find_patterns_respects_the_limit():
    events = []
    for name in "abcdefgh":
        events += spread(f"channel {name}", 20, hour=7, step_days=1)

    assert len(find_patterns(events, limit=3)) <= 3


def test_find_patterns_sorts_strongest_first():
    events = spread("strong", 30, step_days=7) + spread("weak", 30, step_days=1)

    patterns = find_patterns(events)

    if len(patterns) > 1:
        scores = [p["lift"] * p["confidence"] for p in patterns]
        assert scores == sorted(scores, reverse=True)


def test_find_patterns_on_empty_input():
    assert find_patterns([]) == []


def test_every_pattern_carries_its_evidence():
    events = spread("sunday show", 25)

    for pattern in find_patterns(events):
        assert pattern["count"] >= 1
        assert 0 < pattern["confidence"] <= 1
        assert pattern["lift"] > 0
        assert pattern["insight"]


# --- rule quality -----------------------------------------------------------------

def test_a_channel_only_ever_watched_in_one_month_is_not_seasonal():
    """A channel discovered in April and dropped is not a season -- it never had the
    chance to appear elsewhere, so concentration carries no information."""
    discovered = [watch("short lived", datetime(2026, 4, d, 20)) for d in range(1, 26)]
    spread_out = [
        watch("always on", datetime(2026, m, d, 20))
        for m in range(1, 9) for d in (1, 10, 20)
    ]

    rules = seasonal_rules(discovered + spread_out, {"short lived", "always on"})

    assert not [r for r in rules if r["channel"] == "short lived"]


def test_a_channel_watched_year_round_but_peaking_is_seasonal():
    """The real signal: present across the year, concentrated in its season."""
    events = []
    for month in range(1, 9):
        for day in range(1, 4):
            events.append(watch("sport", datetime(2026, month, day, 20)))
    for day in range(1, 31):
        events.append(watch("sport", datetime(2026, 4, day, 20)))
    # Background viewing spread evenly, so April is unremarkable overall and the
    # channel has something to deviate from.
    for month in range(1, 9):
        for day in range(1, 26):
            events.append(watch("background", datetime(2026, month, day, 20)))

    rules = seasonal_rules(events, {"sport", "background"})

    assert rules and rules[0]["month"] == "April"


def test_no_single_rule_type_floods_the_card():
    """Five insights of the same shape read as a bug, not a discovery."""
    events = []
    for i in range(6):
        name = f"seasonal {i}"
        for month in range(1, 9):
            events += [watch(name, datetime(2026, month, d, 20)) for d in (1, 2)]
        events += [watch(name, datetime(2026, 7, d, 20)) for d in range(1, 26)]

    patterns = find_patterns(events, limit=5)

    kinds = collections.Counter(p["type"] for p in patterns)
    assert max(kinds.values()) <= 2, f"one type dominated: {kinds}"