- Зачем: - тикет #90: второй источник должен смотреть на торговую половину дня глазами бэкенда — заказ есть проекция покупки, а не второе порождение; учебный результат — два источника согласованы по построению, а не сверкой. - Что: - `commerce` отдаёт вторым выходом покупки дня: номер заказа, корзину, выручку клиента, промокод и человека за кукой; новых бросков в подпоток `COMMERCE` не добавилось, события дня не сдвинулись. - заведена заказная сторона: подпоток `ORDERS` на позиции 2 дерева зерна (прежнее мёртвое имя `DISCREPANCIES`), ветвление — по дню рождения заказа; `LATECOMERS` не тронут, его наполнит этап 6. - новый модуль `orders.py`: деньги заказа целыми копейками — `items_total` из корзины, `discount` по таблице «код → скидка» (округление вниз), `delivery` броском по таблице весов на полную длину дня, `total` = `items_total` − `discount` + `delivery`. - план состава завёл `person_id` — последним броском когорты, после паспортов: вторая кука пары повторяет ID своего человека, заказ показывает его как `user_id`, в событие Метрики он не попадает. - `world`: окно изменяемости `ORDER_WINDOW_DAYS` = 7 рядом с D0 и поясом, черновая таблица стоимости доставки. - слепок дня в тестах сторожит обе половины: заказы сравниваются наравне с потоком событий. - доки: имя подпотока заказной стороны и состав денег заказа записаны в `docs/architecture/orders`, README генератора знает про новый модуль. - Проверка: - из `generator/`: make lint, make typecheck, make test (417 тестов); в корне — make lint. - опись мира не покраснела: байты восьми дней те же, заказная сторона события не сдвинула. Closes #90 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
312 lines
14 KiB
Python
312 lines
14 KiB
Python
"""План состава: чистота, приток и обещания, данные спекой (разделы 1, 5, 9).
|
||
|
||
Числа мира тесты сторожат вилками спеки, а не точными значениями: менти
|
||
крутит конфигурацию, и падать тесты должны там, где сдвинулся вывод («средний
|
||
магазин на 6–8 тыс. посетителей»), а не при каждой правке.
|
||
"""
|
||
|
||
import inspect
|
||
import re
|
||
|
||
import numpy as np
|
||
import pytest
|
||
|
||
from clickstream_generator import plan, reference, world
|
||
from clickstream_generator.seeds import CANONICAL_SEED
|
||
|
||
# Горизонт эталонного снимка — две недели (спека, раздел 5).
|
||
SNAPSHOT_DAYS = 14
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def fresh_memo():
|
||
"""Когорты запоминаются; тесты сравнивают вычисления, а не ссылки."""
|
||
plan.cohort.cache_clear()
|
||
|
||
|
||
def active_days_of(cohort: plan.Cohort) -> list[tuple[int, int]]:
|
||
"""Таблица парами «кука — день активности», как её видит день-функция."""
|
||
cookies, days = cohort.active_cookie.tolist(), cohort.active_day.tolist()
|
||
return list(zip(cookies, days, strict=True))
|
||
|
||
|
||
def same_cohort(left: plan.Cohort, right: plan.Cohort) -> bool:
|
||
return (
|
||
left.day == right.day
|
||
and left.people == right.people
|
||
and np.array_equal(left.client_id, right.client_id)
|
||
and np.array_equal(left.buyer, right.buyer)
|
||
and np.array_equal(left.birth_day, right.birth_day)
|
||
and np.array_equal(left.active_cookie, right.active_cookie)
|
||
and np.array_equal(left.active_day, right.active_day)
|
||
and np.array_equal(left.pair_cookies, right.pair_cookies)
|
||
and np.array_equal(left.pair_order_days, right.pair_order_days)
|
||
and np.array_equal(left.device, right.device)
|
||
and np.array_equal(left.city, right.city)
|
||
and np.array_equal(left.person_id, right.person_id)
|
||
)
|
||
|
||
|
||
def test_the_plan_is_a_pure_function_of_the_seed():
|
||
first = plan.cohort(CANONICAL_SEED, 3)
|
||
plan.cohort.cache_clear()
|
||
assert same_cohort(first, plan.cohort(CANONICAL_SEED, 3))
|
||
|
||
|
||
def test_counters_are_a_pure_function_of_the_seed():
|
||
first = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS)
|
||
plan.cohort.cache_clear()
|
||
assert first == plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS)
|
||
|
||
|
||
def test_a_remembered_cohort_cannot_be_spoiled_from_outside():
|
||
"""Когорту помнят и раздают всем дням окна: править её нельзя никак."""
|
||
cohort = plan.cohort(CANONICAL_SEED, 0)
|
||
with pytest.raises(ValueError):
|
||
cohort.client_id[0] = 42
|
||
with pytest.raises(ValueError):
|
||
cohort.client_id.flags.writeable = True
|
||
|
||
|
||
def test_another_seed_is_another_world():
|
||
ours = plan.cohort(CANONICAL_SEED, 3)
|
||
theirs = plan.cohort(CANONICAL_SEED + 1, 3)
|
||
assert not same_cohort(ours, theirs)
|
||
|
||
|
||
def test_a_day_costs_the_same_however_far_it_lies():
|
||
"""Горизонт не вход: день 500 стоит ровно столько же когорт, что день 5."""
|
||
plan.audience(CANONICAL_SEED, 5)
|
||
near = plan.cohort.cache_info().misses
|
||
plan.cohort.cache_clear()
|
||
plan.audience(CANONICAL_SEED, 500)
|
||
assert plan.cohort.cache_info().misses == near
|
||
|
||
|
||
def test_the_world_has_a_prehistory_but_no_bottom_under_it():
|
||
assert plan.cohort(CANONICAL_SEED, -world.RETURN_TAIL_DAYS).people > 0
|
||
with pytest.raises(ValueError):
|
||
plan.cohort(CANONICAL_SEED, -world.RETURN_TAIL_DAYS - 1)
|
||
|
||
|
||
def test_events_do_not_start_before_the_origin():
|
||
with pytest.raises(ValueError):
|
||
plan.audience(CANONICAL_SEED, -1)
|
||
|
||
|
||
@pytest.mark.parametrize("day", [-world.RETURN_TAIL_DAYS, -1, 0, 6, 13])
|
||
def test_active_days_stay_inside_the_activity_window(day: int):
|
||
"""Окно активности — хвост возвратов от первого дня человека."""
|
||
cohort = plan.cohort(CANONICAL_SEED, day)
|
||
assert cohort.active_day.min() == day
|
||
assert cohort.active_day.max() <= day + world.RETURN_TAIL_DAYS
|
||
|
||
|
||
def test_every_cookie_is_active_on_the_day_it_was_born():
|
||
cohort = plan.cohort(CANONICAL_SEED, 0)
|
||
cookies = range(cohort.client_id.size)
|
||
born = zip(cookies, cohort.birth_day.tolist(), strict=True)
|
||
assert set(born) <= set(active_days_of(cohort))
|
||
|
||
|
||
def test_a_cookie_gets_one_active_day_at_a_time():
|
||
cohort = plan.cohort(CANONICAL_SEED, 0)
|
||
days = active_days_of(cohort)
|
||
assert days == sorted(days)
|
||
assert len(set(days)) == len(days)
|
||
|
||
|
||
def test_client_ids_are_unique_and_survive_json():
|
||
"""Числа выше 2^53 в JSON округляются — куке столько не нужно."""
|
||
cohort = plan.cohort(CANONICAL_SEED, 0)
|
||
assert len(set(cohort.client_id.tolist())) == cohort.client_id.size
|
||
assert cohort.client_id.max() < 2**53
|
||
|
||
|
||
def test_a_pair_is_one_buyer_with_two_cookies():
|
||
cohort = plan.cohort(CANONICAL_SEED, 0)
|
||
assert cohort.pairs > 0
|
||
first, second = cohort.pair_cookies[:, 0], cohort.pair_cookies[:, 1]
|
||
assert np.all(first < cohort.people)
|
||
assert np.all(second >= cohort.people)
|
||
assert np.all(cohort.buyer[cohort.pair_cookies])
|
||
born_apart = cohort.birth_day[second] - cohort.birth_day[first]
|
||
assert np.all(born_apart > 0)
|
||
assert np.all(born_apart <= world.RETURN_TAIL_DAYS)
|
||
# Вторая кука заводится, пока человек ещё ходит: обычно в первые дни.
|
||
assert np.median(born_apart) < 14
|
||
|
||
|
||
def test_pairs_are_the_agreed_share_of_buyers():
|
||
"""15% покупателей (мастер-спека, раздел 5) — доля решена, не переоткрыта."""
|
||
cohort = plan.cohort(CANONICAL_SEED, 0)
|
||
buyers = int(cohort.buyer[: cohort.people].sum())
|
||
assert 0.10 < cohort.pairs / buyers < 0.20
|
||
assert 0.03 < buyers / cohort.people < 0.07
|
||
|
||
|
||
@pytest.mark.parametrize("day", [-world.RETURN_TAIL_DAYS, -20, 0, 5])
|
||
def test_every_pair_orders_from_both_cookies_on_the_axis(day: int):
|
||
"""Гарантия двухкуковых: заказ назначен на день активности куки, от D0."""
|
||
cohort = plan.cohort(CANONICAL_SEED, day)
|
||
days_of = set(active_days_of(cohort))
|
||
for cookies, days in zip(
|
||
cohort.pair_cookies.tolist(), cohort.pair_order_days.tolist(), strict=True
|
||
):
|
||
assert cookies[0] != cookies[1], "заказы пары — с двух разных кук"
|
||
for cookie, order_day in zip(cookies, days, strict=True):
|
||
assert order_day >= 0
|
||
assert (cookie, order_day) in days_of
|
||
|
||
|
||
def test_the_passport_of_a_pair_is_one_person_with_two_devices():
|
||
"""Два города у одного человека — ложь в данных (спека, раздел 9)."""
|
||
cohort = plan.cohort(CANONICAL_SEED, 0)
|
||
first, second = cohort.pair_cookies[:, 0], cohort.pair_cookies[:, 1]
|
||
assert np.all(cohort.city[first] == cohort.city[second])
|
||
assert np.all(cohort.device[first] != cohort.device[second])
|
||
|
||
# Обещано не просто «разные строки справочника», а разный род устройства:
|
||
# ровно одно из двух — телефон, второе десктоп или планшет. Пара «телефон
|
||
# и ноутбук» мастер-спеки (раздел 5) — про это, а не про запрет планшета.
|
||
category = np.array([row.category for row in reference.DEVICE_PROFILES])
|
||
kinds = category[cohort.device[first]], category[cohort.device[second]]
|
||
assert np.all(kinds[0] != kinds[1])
|
||
phone = category == reference.PHONE_CATEGORY
|
||
assert np.all(phone[cohort.device[first]] != phone[cohort.device[second]])
|
||
|
||
|
||
def test_two_cookies_of_a_pair_are_one_person():
|
||
"""Мост к склейке: у пары один `person_id`, у остальных — свой.
|
||
|
||
Значение непрозрачное и живёт ниже 2^53, как `ClientID`: выше JSON
|
||
округляет при разборе, а заказ повезёт его числом.
|
||
"""
|
||
cohort = plan.cohort(CANONICAL_SEED, 0)
|
||
first, second = cohort.pair_cookies[:, 0], cohort.pair_cookies[:, 1]
|
||
assert cohort.pairs > 0
|
||
assert np.array_equal(cohort.person_id[first], cohort.person_id[second])
|
||
|
||
# Люди когорты — это первые куки: у них личность своя у каждого, а всего
|
||
# разных личностей в когорте ровно столько, сколько людей.
|
||
people = cohort.person_id[: cohort.people]
|
||
assert len(set(people.tolist())) == cohort.people
|
||
assert len(set(cohort.person_id.tolist())) == cohort.people
|
||
assert cohort.person_id.min() > 0
|
||
assert cohort.person_id.max() < 2**53
|
||
|
||
|
||
def test_the_day_audience_knows_the_person_behind_a_cookie():
|
||
"""День спрашивает личность у плана — больше её знать неоткуда."""
|
||
audience = plan.audience(CANONICAL_SEED, 5)
|
||
assert audience.person_id.size == audience.client_id.size
|
||
assert np.all(audience.person_id > 0)
|
||
|
||
|
||
def test_the_passport_points_into_the_directories():
|
||
cohort = plan.cohort(CANONICAL_SEED, 0)
|
||
for passport, table in (
|
||
(cohort.device, reference.DEVICE_PROFILES),
|
||
(cohort.city, reference.CITIES),
|
||
):
|
||
assert passport.size == cohort.client_id.size
|
||
assert passport.min() >= 0
|
||
assert passport.max() < len(table)
|
||
|
||
|
||
def test_the_daily_audience_matches_the_spec_band():
|
||
"""6–8 тыс. посетителей в день — правдоподобный средний магазин."""
|
||
counters = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS)
|
||
assert all(6_000 <= size <= 8_000 for size in counters.audience)
|
||
|
||
|
||
def test_the_influx_holds_its_daily_number():
|
||
counters = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS)
|
||
average = sum(counters.new_cookies) / len(counters.new_cookies)
|
||
assert abs(average - world.DAILY_INFLUX) < world.DAILY_INFLUX // 10
|
||
|
||
|
||
def test_the_influx_breathes_with_the_week():
|
||
"""Приток модулируется тем же недельным профилем, что трафик."""
|
||
|
||
def mean(numbers: tuple[int, ...] | list[int]) -> float:
|
||
return sum(numbers) / len(numbers)
|
||
|
||
counters = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS)
|
||
weekdays = [size for day, size in enumerate(counters.new_cookies) if day % 7 < 5]
|
||
weekend = [size for day, size in enumerate(counters.new_cookies) if day % 7 >= 5]
|
||
expected = mean(world.WEEKLY_PROFILE_PERCENT[5:]) / mean(
|
||
world.WEEKLY_PROFILE_PERCENT[:5]
|
||
)
|
||
assert abs(mean(weekend) / mean(weekdays) - expected) < 0.03
|
||
|
||
|
||
def test_the_influx_differs_even_on_the_same_weekday():
|
||
"""Ровный приток выдал бы себя в первом же графике по дням."""
|
||
mondays = {plan.cohort(CANONICAL_SEED, day).people for day in (0, 7, 14, 21)}
|
||
assert len(mondays) > 1
|
||
|
||
|
||
def test_the_influx_is_visible_on_the_plan():
|
||
"""Накопленная аудитория растёт с горизонтом и с дневной не сходится."""
|
||
week = plan.counters(CANONICAL_SEED, 7)
|
||
fortnight = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS)
|
||
assert week.visitors < fortnight.visitors
|
||
assert fortnight.visitors > 2 * max(fortnight.audience)
|
||
|
||
|
||
def test_the_horizon_is_a_prefix_not_another_world():
|
||
"""Продление истории днём N+1 не трогает дни 1…N."""
|
||
week = plan.counters(CANONICAL_SEED, 7)
|
||
fortnight = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS)
|
||
assert fortnight.audience[:7] == week.audience
|
||
assert fortnight.new_cookies[:7] == week.new_cookies
|
||
|
||
|
||
def test_counters_count_the_same_audience_that_the_day_gets():
|
||
"""Счётчики и дня-функция спрашивают один план — расходиться им негде."""
|
||
counters = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS)
|
||
for day in (0, 7, SNAPSHOT_DAYS - 1):
|
||
actual = plan.audience(CANONICAL_SEED, day).client_id.size
|
||
assert actual == counters.audience[day]
|
||
|
||
|
||
def test_counters_know_the_pairs_before_a_single_event():
|
||
counters = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS)
|
||
assert counters.pairs > 0
|
||
|
||
|
||
def test_the_audience_of_a_day_carries_its_assigned_orders():
|
||
audience = plan.audience(CANONICAL_SEED, 5)
|
||
assert audience.client_id.size == audience.buyer.size
|
||
assert audience.assigned_order.sum() > 0
|
||
assert np.all(audience.buyer[audience.assigned_order])
|
||
assert len(set(audience.client_id.tolist())) == audience.client_id.size
|
||
|
||
|
||
def test_randomness_is_drawn_in_whole_numbers():
|
||
"""Дисциплина спеки (раздел 2): целые числа, никакой системной математики.
|
||
|
||
Сторож — разрешительный: плавающие распределения numpy расходятся между
|
||
архитектурами и версиями (NEP 19), поэтому в план пускается только выбор
|
||
целого.
|
||
"""
|
||
for module in (plan, world):
|
||
source = inspect.getsource(module)
|
||
assert set(re.findall(r"\brng\.(\w+)", source)) <= {"integers"}
|
||
assert not re.search(r"^\s*import (random|math)\b", source, re.MULTILINE)
|
||
|
||
|
||
def test_plan_arrays_are_whole_numbers():
|
||
cohort = plan.cohort(CANONICAL_SEED, 0)
|
||
for array in (
|
||
cohort.client_id,
|
||
cohort.birth_day,
|
||
cohort.active_cookie,
|
||
cohort.active_day,
|
||
cohort.pair_cookies,
|
||
cohort.pair_order_days,
|
||
cohort.person_id,
|
||
):
|
||
assert np.issubdtype(array.dtype, np.integer)
|