feat(generator): торговые события — корзина, покупка, сырой ecommerce

- Зачем:
  - клиентская сторона мира становится целой: без add_to_cart и purchase
    в данных нет ни таксономии событий, ни вложенного JSON, ни денег,
    а метка «покупатель» из плана состава ни на что не влияла (#40).
- Что:
  - добавлен модуль commerce: корзина шире заказа, номер заказа вида
    ГГГГММДД-NNNN, промокод без скидки в сумме, сырой ecommerce через orjson;
  - метка покупателя получила два рычага — долгую жизнь куки в плане и
    свою воронку в дне; CART_PERCENT опущен с 8 до 6, чтобы конверсия
    мира осталась около 2%;
  - часть цен каталога получила копейки: productPrice округляется форматом,
    purchaseRevenue несёт точную сумму — расхождение живёт внутри события;
  - граница суток забирает страницу подтверждения вместе с её покупкой:
    потерь на клиентской стороне этот этап не заводит;
  - решения и перемеренные числа мира записаны в спеку генератора, §9.
- Проверка:
  - make lint && make typecheck && make test — 383 passed (было 353).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-02 19:13:30 +03:00
co-authored by Claude Opus 5
parent bfbaa96696
commit 722dbe22b7
19 changed files with 1591 additions and 210 deletions
+12
View File
@@ -54,6 +54,18 @@ def test_every_row_is_filled_and_priced_in_whole_kopecks():
assert low <= int(row["price"]) <= high, row["sku"]
def test_part_of_the_prices_carry_kopecks():
"""Без копеек урок про Float64 беспредметен: округлять было бы нечего.
Сторожится не доля, а то, на чём стоит урок: цены бывают и кратные
рублю, и с копейками — тогда `productPrice` округляется форматом, а
`purchaseRevenue` несёт точную сумму.
"""
kopecks = [int(row["price"]) % 100 for row in rows()]
assert any(rest for rest in kopecks)
assert any(not rest for rest in kopecks)
def test_every_category_of_the_assortment_is_covered():
"""Каталог покрывает ассортимент целиком: пустых категорий не бывает."""
present = {row["category"] for row in rows()}
+488
View File
@@ -0,0 +1,488 @@
"""Торговые события: корзина шире заказа, деньги целые, номера читаемые.
Числа мира тесты сторожат вилками спеки, а не точными значениями: менти
крутит конфигурацию, и падать тесты должны там, где сдвинулся вывод («заказ
уже корзины», «конверсия визита около 2%»), а не при каждой правке.
Неделя дня-функции стоит секунд, поэтому дни, которые нужны нескольким
тестам, считаются один раз на модуль.
"""
import json
from typing import Any
import numpy as np
import pytest
from numpy.typing import NDArray
from clickstream_generator import catalog, commerce, day, plan, schema, world
from clickstream_generator.reference import Page
from clickstream_generator.seeds import CANONICAL_SEED, Component
WEEKDAY = 2
WEEK = 7
@pytest.fixture(autouse=True)
def fresh_memo():
"""Когорты запоминаются; тесты сравнивают вычисления, а не ссылки."""
plan.cohort.cache_clear()
@pytest.fixture(scope="module")
def weekday() -> day.Day:
return day.stream(CANONICAL_SEED, WEEKDAY)
@pytest.fixture(scope="module")
def week() -> list[day.Day]:
"""Неделя мира: повторные покупки и обещания пар живут между днями."""
return [day.stream(CANONICAL_SEED, number) for number in range(WEEK)]
def rows_of(events: day.Day, kind: str) -> dict[str, NDArray[Any]]:
"""Строки одного типа события — все колонки разом."""
here = events.columns["EventType"] == kind
return {name: value[here] for name, value in events.columns.items()}
def cells(column: NDArray[Any]) -> list[list[Any]]:
return [cell.tolist() for cell in column]
def raw_of(rows: dict[str, NDArray[Any]]) -> list[dict[str, Any]]:
"""Сырой `ecommerce` разобранный — чужим разбором, не своим сборщиком."""
return [json.loads(text) for text in rows["ecommerce"]]
def by_visit(events: day.Day) -> NDArray[np.int64]:
"""Порядок строк «визит, потом время» — так их читает лаба сессий."""
return np.lexsort((events.columns["UTCEventTime"], events.columns["VisitID"]))
def test_the_day_carries_both_trade_events(weekday: day.Day):
"""Таксономия ожила: в дне есть и корзина, и покупка."""
carts = rows_of(weekday, commerce.ADD_TO_CART)
orders = rows_of(weekday, commerce.PURCHASE)
assert orders["WatchID"].size > 100
# Корзина шире заказа и по событиям: не всякая корзина доходит до кассы.
assert carts["WatchID"].size > 2 * orders["WatchID"].size
def confirmed_visits(events: day.Day) -> set[int]:
"""Визиты, чья страница подтверждения дожила до потока."""
pageview = events.columns["EventType"] == "pageview"
here = pageview & (events.page == Page.CONFIRMATION)
return set(events.columns["VisitID"][here].tolist())
def test_a_confirmation_in_the_stream_always_has_its_purchase(weekday: day.Day):
"""Клиентская сторона честная: подтверждение без покупки — это потеря.
Потери и дубли стенд заводит намеренно и позже (этапы 4 и 6); здесь их
быть не должно ни одной. Обратное тоже верно: покупка без подтверждения
в потоке — заказ ниоткуда.
"""
bought = set(rows_of(weekday, commerce.PURCHASE)["VisitID"].tolist())
assert confirmed_visits(weekday) == bought
def test_the_day_boundary_takes_the_confirmation_together_with_its_purchase(
monkeypatch: pytest.MonkeyPatch,
):
"""Полночь режет подтверждение и покупку вместе — или не режет ни одну.
Иначе визит отдал бы страницу «Заказ оформлен» без события покупки, и на
клиентской стороне завелась бы потеря. Хвост торгового события здесь
растянут до часа: в каноническом мире он секунды, и такой визит
выпадает раз в годы, а правило обязано держаться при любом хвосте.
"""
monkeypatch.setattr(world, "TRADE_DELAY_SECONDS", (3_000, 3_600))
monkeypatch.setattr(day, "TRADE_TAIL_SECONDS", 3_600)
events = day.stream(CANONICAL_SEED, WEEKDAY)
bought = set(rows_of(events, commerce.PURCHASE)["VisitID"].tolist())
assert bought
assert confirmed_visits(events) == bought
def test_a_purchase_carries_exactly_one_order_number(weekday: day.Day):
"""Одно подтверждение — один заказ (мастер-спека, раздел 4)."""
orders = rows_of(weekday, commerce.PURCHASE)
for name in ("purchaseID", "purchaseRevenue", "purchaseCurrency", "purchaseCoupon"):
assert {cell.size for cell in orders[name]} == {1}, name
assert {cell[0] for cell in orders["purchaseCurrency"]} == {world.CURRENCY}
def test_the_cart_event_has_no_order_block(weekday: day.Day):
"""У корзины заказа ещё нет: блок `purchase*` пуст, как у просмотра."""
carts = rows_of(weekday, commerce.ADD_TO_CART)
for name in ("purchaseID", "purchaseRevenue", "purchaseCurrency", "purchaseCoupon"):
assert {cell.size for cell in carts[name]} == {0}, name
def test_the_product_arrays_of_an_event_share_one_length(weekday: day.Day):
"""Длина общая внутри группы `product*`; с `purchase*` она не совпадает."""
trade = weekday.columns["EventType"] != "pageview"
names = [
column.name for column in schema.COLUMNS if column.name.startswith("product")
]
assert len(names) == 6
sizes = np.array(
[[cell.size for cell in weekday.columns[name][trade]] for name in names]
)
assert np.all(sizes == sizes[0])
# Корзина — всегда один товар, заказ — от одного и больше.
carts = rows_of(weekday, commerce.ADD_TO_CART)
assert {cell.size for cell in carts["productID"]} == {1}
orders = rows_of(weekday, commerce.PURCHASE)
assert min(cell.size for cell in orders["productID"]) == 1
assert max(cell.size for cell in orders["productID"]) > 1
def test_the_raw_json_agrees_with_the_flat_arrays(weekday: day.Day):
"""Сырое и разобранное — одно событие, поле в поле.
Сверяется всё, что есть по обе стороны: разъедься хоть одно поле, лаба
«сырое против разобранного» учила бы неправде. Цена — единственная пара,
где расхождение законно: в колонке целые рубли, в сыром точная сумма,
поэтому сверяется не равенство, а что колонка — та же цена, округлённая
до рубля.
"""
for kind, action in (
(commerce.ADD_TO_CART, commerce.ADD_ACTION),
(commerce.PURCHASE, commerce.PURCHASE_ACTION),
):
rows = rows_of(weekday, kind)
assert rows["ecommerce"].size > 100
for number, block in enumerate(raw_of(rows)):
assert block["currencyCode"] == world.CURRENCY
products = block[action]["products"]
for field, column in (
("id", "productID"),
("name", "productName"),
("category", "productCategory"),
("quantity", "productQuantity"),
):
assert [item[field] for item in products] == (
rows[column][number].tolist()
), (kind, field)
assert rows["productEventType"][number].tolist() == [action] * len(products)
prices = zip(
(item["price"] for item in products),
rows["productPrice"][number].tolist(),
strict=True,
)
assert all(abs(exact - whole) <= 0.5 for exact, whole in prices)
orders = rows_of(weekday, commerce.PURCHASE)
for number, block in enumerate(raw_of(orders)):
field = block[commerce.PURCHASE_ACTION]["actionField"]
assert field["id"] == orders["purchaseID"][number][0]
assert field["revenue"] == orders["purchaseRevenue"][number][0]
def test_the_raw_json_carries_what_the_arrays_do_not(weekday: day.Day):
"""Иначе лаба «сырое против разобранного» разбирала бы то же самое."""
orders = rows_of(weekday, commerce.PURCHASE)
goods = catalog.catalog()
brands = set(goods.brand.tolist())
seen_brands: set[str] = set()
for block in raw_of(orders):
for item in block[commerce.PURCHASE_ACTION]["products"]:
assert item["brand"] in brands
assert item["variant"]
seen_brands.add(item["brand"])
assert len(seen_brands) > 1
assert not any(
column.name in ("productBrand", "productVariant") for column in schema.COLUMNS
)
def test_money_is_whole_kopecks_inside_and_float_only_on_the_surface(weekday: day.Day):
"""Деньги целые; дробное число в событии одно — выручка (спека, раздел 2)."""
floats = [
column.name for column in schema.COLUMNS if column.numpy_dtype == "float64"
]
assert floats == ["purchaseRevenue"]
goods = catalog.catalog()
orders = rows_of(weekday, commerce.PURCHASE)
sku = {article: number for number, article in enumerate(goods.sku.tolist())}
for number, revenue in enumerate(orders["purchaseRevenue"]):
assert revenue.dtype == np.float64
kopecks = sum(
int(goods.price[sku[article]]) * pieces
for article, pieces in zip(
orders["productID"][number].tolist(),
orders["productQuantity"][number].tolist(),
strict=True,
)
)
assert revenue[0] == kopecks / 100
assert orders["productPrice"][number].dtype == np.int64
def test_the_rounded_price_does_not_add_up_to_the_revenue(weekday: day.Day):
"""Часть цен несёт копейки, и выручку по массивам события не пересобрать.
Разрыв живёт внутри одного события: `productPrice` округлён форматом,
`purchaseRevenue` точна. Это и есть урок про Float64 — не выдуманный.
"""
orders = rows_of(weekday, commerce.PURCHASE)
apart = 0
for number, revenue in enumerate(orders["purchaseRevenue"]):
by_arrays = float(
(orders["productPrice"][number] * orders["productQuantity"][number]).sum()
)
apart += by_arrays != revenue[0]
assert 0.1 < apart / orders["WatchID"].size < 0.9
def test_the_order_is_narrower_than_the_cart(weekday: day.Day):
"""Часть положенных позиций не куплена — иначе сравнивать было бы нечего."""
carts = rows_of(weekday, commerce.ADD_TO_CART)
orders = rows_of(weekday, commerce.PURCHASE)
put: dict[int, set[str]] = {}
for visit, article in zip(
carts["VisitID"].tolist(), (cell[0] for cell in carts["productID"]), strict=True
):
put.setdefault(visit, set()).add(article)
dropped = whole = 0
for visit, bought in zip(
orders["VisitID"].tolist(), cells(orders["productID"]), strict=True
):
assert bought, "пустых заказов не бывает"
assert set(bought) <= put[visit], "куплено то, чего не клали в корзину"
assert len(set(bought)) == len(bought), "позиция в заказе одна на товар"
dropped += len(put[visit]) - len(bought)
whole += len(put[visit])
assert 0.05 < dropped / whole < 0.30
def test_the_cart_holds_only_what_the_visitor_opened(weekday: day.Day):
"""Товар в корзине, которого никто не открывал, — глупость в воронке."""
order = by_visit(weekday)
visit = weekday.columns["VisitID"][order]
kind = weekday.columns["EventType"][order]
product = weekday.product[order]
page = weekday.page[order]
shown: dict[int, set[int]] = {}
for number, this in enumerate(visit.tolist()):
if kind[number] == "pageview" and page[number] == Page.PRODUCT:
shown.setdefault(this, set()).add(int(product[number]))
put: dict[int, list[int]] = {}
for number, this in enumerate(visit.tolist()):
if kind[number] == commerce.ADD_TO_CART:
put.setdefault(this, []).append(int(product[number]))
assert put
for this, products in put.items():
assert set(products) <= shown[this]
# Карточка, открытая дважды, даёт одну позицию, а не две.
assert len(set(products)) == len(products)
def test_a_trade_event_sits_on_the_page_that_sent_it(weekday: day.Day):
"""Корзина — на карточке того самого товара, покупка — на подтверждении.
Событие не просто стоит после какой-то страницы: у события корзины
предыдущая строка визита — карточка именно этого товара, и товар в
массивах события тот же. Иначе в корзину попал бы товар, которого
посетитель не открывал, а событие село бы на чужую страницу.
"""
order = by_visit(weekday)
columns = {name: value[order] for name, value in weekday.columns.items()}
page, product = weekday.page[order], weekday.product[order]
kind = columns["EventType"]
goods = catalog.catalog()
trade = np.flatnonzero(kind != "pageview")
assert trade.size > 100
for row in trade.tolist():
assert columns["VisitID"][row] == columns["VisitID"][row - 1]
assert columns["URL"][row] == columns["URL"][row - 1]
assert columns["Referer"][row] == columns["Referer"][row - 1]
away = (columns["UTCEventTime"][row] - columns["UTCEventTime"][row - 1]).astype(
"int64"
)
assert 0 < away < world.TRADE_DELAY_SECONDS[1]
if kind[row] == commerce.ADD_TO_CART:
assert page[row] == page[row - 1] == Page.PRODUCT
# Карточка предыдущей строки — карточка этого самого товара.
assert product[row] == product[row - 1] >= 0
assert columns["productID"][row].tolist() == [goods.sku[product[row]]]
# У страницы входа в адресе ещё метки перехода — путь до «?».
address = columns["URL"][row].split("?")[0]
assert address.endswith(f"/product/{goods.sku[product[row]]}")
else:
assert page[row] == page[row - 1] == Page.CONFIRMATION
assert kind[row - 1] == "pageview"
def test_the_goals_repeat_the_trade_events(weekday: day.Day):
"""Цели дублируют события — в бою так и бывает (мастер-спека, 1.2)."""
goals = {
kind: {tuple(cell.tolist()) for cell in rows_of(weekday, kind)["GoalsReached"]}
for kind in ("pageview", commerce.ADD_TO_CART, commerce.PURCHASE)
}
assert goals["pageview"] == {()}
assert goals[commerce.ADD_TO_CART] == {(world.GOAL_CART_ID,)}
assert goals[commerce.PURCHASE] == {(world.GOAL_PURCHASE_ID,)}
def test_the_order_number_reads_as_the_day_and_the_count(weekday: day.Day):
"""Номер — день и порядковый номер покупки в нём; он же `order_id` бэкенда."""
orders = rows_of(weekday, commerce.PURCHASE)
numbers = [cell[0] for cell in orders["purchaseID"]]
date = str(weekday.columns["EventDate"][0]).replace("-", "")
assert numbers == [f"{date}-{place:04d}" for place in range(1, len(numbers) + 1)]
assert len(set(numbers)) == len(numbers)
# Нумерация идёт в порядке событий: строки дня уже упорядочены по времени.
assert list(orders["UTCEventTime"]) == sorted(orders["UTCEventTime"])
def test_the_coupon_comes_from_the_world_table_and_does_not_touch_the_revenue(
weekday: day.Day,
):
"""Промокод в событии есть, скидки в сумме нет: клиент шлёт сумму позиций."""
orders = rows_of(weekday, commerce.PURCHASE)
known = {code for code, _ in world.COUPONS}
coupons = [cell[0] for cell in orders["purchaseCoupon"]]
assert set(coupons) <= known | {""}
with_coupon = [code for code in coupons if code]
assert 0.05 < len(with_coupon) / len(coupons) < 0.40
assert len(set(with_coupon)) > 1
# Скидка в клиентскую выручку не входит: сумма сходится с позициями и с
# тем, что уехало в сыром JSON.
for number, block in enumerate(raw_of(orders)):
action = block[commerce.PURCHASE_ACTION]["actionField"]
assert action["revenue"] == orders["purchaseRevenue"][number][0]
assert ("coupon" in action) == bool(coupons[number])
def test_editing_the_trade_flow_does_not_move_the_traffic(
monkeypatch: pytest.MonkeyPatch,
):
"""Иерархия подпотоков: у торговли своя случайность (спека, раздел 2)."""
ours = day.stream(CANONICAL_SEED, WEEKDAY)
plan.cohort.cache_clear()
monkeypatch.setattr(world, "ABANDONED_POSITION_PERCENT", 40)
other = day.stream(CANONICAL_SEED, WEEKDAY)
def pageviews(events: day.Day) -> dict[str, list[Any]]:
rows = rows_of(events, "pageview")
return {
name: [
cell.tolist() if isinstance(cell, np.ndarray) else cell
for cell in value
]
for name, value in rows.items()
}
assert pageviews(other) == pageviews(ours)
assert [
cell.tolist() for cell in rows_of(other, commerce.PURCHASE)["productID"]
] != [cell.tolist() for cell in rows_of(ours, commerce.PURCHASE)["productID"]]
def test_the_trade_flow_draws_from_its_own_named_branch(
monkeypatch: pytest.MonkeyPatch,
):
"""Торговля берёт подпоток `COMMERCE` — именно его, а не любой другой.
Неподвижности трафика для этого мало: возьми торговля ветвь трафика,
трафик всё равно не сдвинулся бы — свои броски он сделал раньше и из
своего генератора. Разошлись бы только два подпотока, ставшие одним, а
в дереве зерна место каждого компонента и есть контракт (спека,
раздел 2). Поэтому проверяется адрес, по которому торговля пришла.
"""
asked: list[tuple[int, int, Component]] = []
honest = commerce.day_stream
def spy(seed: int, day_number: int, component: Component):
asked.append((seed, day_number, component))
return honest(seed, day_number, component)
monkeypatch.setattr(commerce, "day_stream", spy)
day.stream(CANONICAL_SEED, WEEKDAY)
assert asked == [(CANONICAL_SEED, WEEKDAY, Component.COMMERCE)]
def test_the_promised_orders_of_a_pair_become_purchases(week: list[day.Day]):
"""Гарантия плана: обе куки пары покупают в назначенные им дни.
Проверяются пары, у которых оба назначенных дня попали внутрь недели:
остальные ждут своего дня за горизонтом.
"""
bought = [
set(rows_of(events, commerce.PURCHASE)["ClientID"].tolist()) for events in week
]
pairs = 0
for born in range(-world.RETURN_TAIL_DAYS, WEEK):
cohort = plan.cohort(CANONICAL_SEED, born)
for cookies, days in zip(
cohort.pair_cookies.tolist(), cohort.pair_order_days.tolist(), strict=True
):
if max(days) >= WEEK:
continue
pairs += 1
for cookie, number in zip(cookies, days, strict=True):
assert int(cohort.client_id[cookie]) in bought[number]
assert pairs > 10
# Счётчик пар в плане и пары, реализованные в дне, — про одно и то же.
assert pairs == plan.counters(CANONICAL_SEED, WEEK).pairs
def paired_cookies() -> set[int]:
"""Куки двухкуковых пар: их заказы обещаны планом, а не решены днём."""
cookies: set[int] = set()
for born in range(-world.RETURN_TAIL_DAYS, WEEK):
cohort = plan.cohort(CANONICAL_SEED, born)
cookies |= set(cohort.client_id[cohort.pair_cookies.ravel()].tolist())
return cookies
def test_flagged_buyers_buy_again_much_more_often(week: list[day.Day]):
"""Метка покупателя перестала быть словом: у неё виден след в данных.
«Постоянный покупатель» читается только так — как кука, которая ходит
неделями и покупает не раз: метки в событии нет и не будет.
Куки двухкуковых пар из счёта исключены: их заказы назначил план, и
вместе с ними лифт мерил бы гарантию, а не два новых рычага. Остаются
те помеченные, чьи покупки решил день: повторно покупают 4,6% из них
против 1,5% прочих — втрое чаще при пороге «вдвое».
"""
purchases: dict[int, int] = {}
flagged: set[int] = set()
for number, events in enumerate(week):
audience = plan.audience(CANONICAL_SEED, number)
flagged |= set(audience.client_id[audience.buyer].tolist())
for cookie in rows_of(events, commerce.PURCHASE)["ClientID"].tolist():
purchases[cookie] = purchases.get(cookie, 0) + 1
def repeat_share(cookies: set[int]) -> float:
again = sum(1 for cookie in cookies if purchases[cookie] > 1)
return again / len(cookies)
buyers = set(purchases)
by_levers = (buyers & flagged) - paired_cookies()
others = buyers - flagged
assert len(by_levers) > 100
assert len(others) > 100
assert repeat_share(by_levers) > 2 * repeat_share(others)
def test_the_shop_stays_the_same_plausible_shop(week: list[day.Day]):
"""Границы правки — вилки спеки (раздел 5): аудитория, объём, конверсия."""
counters = plan.counters(CANONICAL_SEED, WEEK)
assert all(6_000 <= size <= 8_000 for size in counters.audience)
for events in week:
visits = len(set(events.columns["VisitID"].tolist()))
orders = int((events.columns["EventType"] == commerce.PURCHASE).sum())
assert 40_000 < len(events) < 60_000
assert 0.015 < orders / visits < 0.035
+45 -19
View File
@@ -1,4 +1,4 @@
"""День-функция: чистота, форма волны, правила резки визитов, шов для #40.
"""День-функция: чистота, форма волны, правила резки визитов, шов с торговлей.
Числа мира тесты сторожат вилками спеки, а не точными значениями: менти
крутит конфигурацию, и падать тесты должны там, где сдвинулся вывод («средний
@@ -14,7 +14,16 @@ import numpy as np
import pytest
from numpy.typing import NDArray
from clickstream_generator import catalog, day, plan, reference, schema, world
from clickstream_generator import (
catalog,
commerce,
day,
ids,
plan,
reference,
schema,
world,
)
from clickstream_generator.reference import Page
from clickstream_generator.seeds import CANONICAL_SEED
@@ -137,25 +146,36 @@ def test_every_column_of_the_contract_is_present_and_typed(weekday: day.Day):
assert values.dtype == np.dtype(column.numpy_dtype), column.name
def test_what_is_left_to_the_trade_ticket_is_empty_not_missing(weekday: day.Day):
def test_a_pageview_carries_the_trade_columns_empty_not_missing(weekday: day.Day):
"""Пусто — пустой массив и пустая строка, а ключ есть у каждого события.
Проверяются все колонки будущих торговых событий, а не выбранные: тикет
#40 добавит свои, и они должны попасть под тот же сторож.
Проверяются все торговые колонки, а не выбранные: у просмотра страницы
пусты они все до одной, иначе строгий приём хранилища не уживётся с
полями, пустыми по смыслу (мастер-спека, раздел 6).
"""
waiting = [
pageview = weekday.columns["EventType"] == "pageview"
assert pageview.mean() > 0.9
trade = [
column
for column in schema.COLUMNS
if column.group in (schema.ColumnGroup.ECOMMERCE, schema.ColumnGroup.PARAMS)
]
assert len(waiting) > 10
for column in waiting:
cells = weekday.columns[column.name][:1000]
assert len(trade) > 10
for column in trade:
cells = weekday.columns[column.name][pageview][:1000]
if column.clickhouse_type.startswith("Array("):
assert all(cell.size == 0 for cell in cells), column.name
else:
assert set(cells) == {""}, column.name
assert set(weekday.columns["EventType"].tolist()) == {"pageview"}
def test_the_taxonomy_is_three_event_types(weekday: day.Day):
"""`EventType` — добавка стенда: таксономия нужна явно (мастер-спека, 1.1)."""
assert set(weekday.columns["EventType"].tolist()) == {
"pageview",
commerce.ADD_TO_CART,
commerce.PURCHASE,
}
def test_no_column_hides_a_hole(weekday: day.Day):
@@ -171,7 +191,7 @@ def test_no_column_hides_a_hole(weekday: day.Day):
def test_identifiers_survive_json(weekday: day.Day):
"""Числа выше 2^53 в JSON округляются — идентификаторам столько не нужно."""
for name in ("WatchID", "VisitID", "ClientID"):
assert weekday.columns[name].max() < day.ID_LIMIT
assert weekday.columns[name].max() < ids.LIMIT
assert weekday.columns[name].dtype == np.uint64
@@ -229,7 +249,7 @@ def test_the_counter_timezone_moves_the_date_apart_from_utc(weekday: day.Day):
def test_the_scale_of_an_average_day_is_about_fifty_thousand(weekday: day.Day):
"""Порядок величины (спека, раздел 5); итог сложится после #40."""
"""Порядок величины: ~50 тыс. событий в средний день (спека, раздел 5)."""
assert 30_000 < len(weekday) < 70_000
visits = len(set(weekday.columns["VisitID"].tolist()))
assert 6_000 < visits < 14_000
@@ -256,7 +276,7 @@ def test_the_weekend_is_shaped_unlike_a_weekday(weekday: day.Day):
def test_the_funnel_converts_about_two_percent_of_visits(weekday: day.Day):
visits = len(set(weekday.columns["VisitID"].tolist()))
ordered = int((weekday.page == Page.CONFIRMATION).sum())
ordered = int((weekday.columns["EventType"] == commerce.PURCHASE).sum())
assert 0.01 < ordered / visits < 0.04
@@ -270,7 +290,7 @@ def test_the_promised_orders_reach_the_confirmation(weekday: day.Day):
def test_the_cart_always_follows_a_product_card(weekday: day.Day):
"""Шов для #40: товар в корзине посетитель до того открывал."""
"""Шов с торговлей: товар в корзине посетитель до того открывал."""
order = np.lexsort((local_seconds(weekday), weekday.columns["VisitID"]))
by_visit = weekday.page[order]
carts = np.flatnonzero(by_visit == Page.CART)
@@ -292,11 +312,17 @@ def test_the_product_of_a_card_is_the_seam_for_trade_events(weekday: day.Day):
def test_the_referer_is_the_page_before(weekday: day.Day):
"""Внутри визита реферер — предыдущий адрес; на входе — адрес источника."""
order = np.lexsort((local_seconds(weekday), weekday.columns["VisitID"]))
url = weekday.columns["URL"][order]
referer = weekday.columns["Referer"][order]
visit = weekday.columns["VisitID"][order]
"""Внутри визита реферер — предыдущий адрес; на входе — адрес источника.
Цепочка считается по просмотрам страниц: торговое событие не открывает
страницу, а живёт на уже открытой, и реферер у него тот же, что у неё.
"""
pageview = weekday.columns["EventType"] == "pageview"
columns = {name: value[pageview] for name, value in weekday.columns.items()}
order = np.lexsort((columns["UTCEventTime"], columns["VisitID"]))
url = columns["URL"][order]
referer = columns["Referer"][order]
visit = columns["VisitID"][order]
inside = np.flatnonzero(visit[1:] == visit[:-1]) + 1
assert np.all(referer[inside] == url[inside - 1])
+41
View File
@@ -0,0 +1,41 @@
"""Идентификаторы событий: неповторяющиеся и переживающие JSON.
Обещание одно, но держит его дедупликация при переигровке дня: два
одинаковых `WatchID` склеили бы разные события (спека, раздел 4).
"""
import numpy as np
from clickstream_generator import ids
from clickstream_generator.seeds import CANONICAL_SEED
def stream(offset: int = 0) -> np.random.Generator:
return np.random.Generator(np.random.PCG64(CANONICAL_SEED + offset))
def test_ids_do_not_repeat_and_survive_json():
numbers = ids.unique(stream(), 10_000)
assert numbers.dtype == np.uint64
assert len(set(numbers.tolist())) == numbers.size
assert numbers.max() < ids.LIMIT
assert numbers.min() > 0
def test_ids_do_not_give_away_the_order_of_the_rows():
"""Иначе номер события рассказывал бы, каким по счёту оно родилось."""
numbers = ids.unique(stream(), 1_000)
assert not np.all(np.diff(numbers.astype(np.int64)) > 0)
def test_ids_of_another_stream_are_other_ids():
assert not np.array_equal(ids.unique(stream(), 100), ids.unique(stream(1), 100))
def test_ids_can_be_drawn_beside_the_ones_already_taken():
"""Торговые строки берут номера из своего подпотока и не задевают чужие."""
taken = ids.unique(stream(), 5_000)
numbers = ids.unique_apart_from(stream(1), 500, taken)
assert len(set(numbers.tolist())) == numbers.size
assert not set(numbers.tolist()) & set(taken.tolist())
assert numbers.max() < ids.LIMIT
+55 -13
View File
@@ -15,6 +15,13 @@ def mean_by_weights(values: tuple[int, ...], weights: tuple[int, ...]) -> float:
return weighted / sum(weights)
def active_days(one_shot_percent: int, return_weights: tuple[int, ...]) -> float:
"""Сколько дней в среднем ходит кука: день рождения плюс возвраты."""
counts = tuple(range(1, len(return_weights) + 1))
returns = mean_by_weights(counts, return_weights)
return 1 + (100 - one_shot_percent) / 100 * returns
def test_origin_is_a_monday():
"""День недели считается как остаток номера дня — это верно от понедельника."""
assert world.ORIGIN.weekday() == 0
@@ -36,10 +43,7 @@ def test_returns_average_three_to_four():
def test_average_cookie_is_active_about_two_days():
"""Сходимость с разделом 5: ≈1,9 активного дня на куку — отсюда 6–8 тыс."""
counts = tuple(range(1, len(world.RETURN_COUNT_WEIGHTS) + 1))
returns = mean_by_weights(counts, world.RETURN_COUNT_WEIGHTS)
active_days = 1 + (100 - world.ONE_SHOT_PERCENT) / 100 * returns
assert 1.8 <= active_days <= 2.0
assert 1.8 <= active_days(world.ONE_SHOT_PERCENT, world.RETURN_COUNT_WEIGHTS) <= 2.0
def test_return_delays_cover_the_whole_activity_window():
@@ -108,12 +112,50 @@ def test_pauses_stay_inside_the_visit_timeout():
assert max(world.LONG_PAUSE_SECONDS) < world.VISIT_TIMEOUT_SECONDS
def test_the_funnel_converts_about_two_percent_of_visits():
"""Конверсия ~2% на сессию (спека, раздел 9) — произведение трёх шагов."""
conversion = (
world.CART_PERCENT
* world.CHECKOUT_OF_CART_PERCENT
* world.CONFIRMATION_OF_CHECKOUT_PERCENT
/ 100**2
)
assert 1.5 <= conversion <= 2.5
def test_the_trade_event_never_overtakes_the_next_page():
"""Торговое событие отстаёт от своей страницы меньше, чем длится пауза.
На этом стоят два свойства сразу: событие корзины не обгоняет страницу,
на которой посетитель нажал кнопку, и не выходит за полночь — за ним в
том же визите всегда идёт страница, которая полночь пережила.
"""
assert max(world.TRADE_DELAY_SECONDS) <= min(world.PAGE_PAUSE_SECONDS)
def conversion(cart: int, checkout_of_cart: int) -> float:
"""Конверсия визита в заказ, проценты: произведение трёх шагов воронки."""
return cart * checkout_of_cart * world.CONFIRMATION_OF_CHECKOUT_PERCENT / 100**2
def test_the_ordinary_visitor_converts_a_bit_below_the_world():
"""Воронка обычного посетителя: ~1,5% визитов в оформленный заказ.
До ~2% конверсию мира (спека, раздел 9) добирают помеченные покупатели
и обещанные планом заказы пар, поэтому здесь сторожится слагаемое, а не
итог: сам итог меряет день-функция на собранном дне
(`test_the_shop_stays_the_same_plausible_shop`).
"""
assert 1.2 <= conversion(world.CART_PERCENT, world.CHECKOUT_OF_CART_PERCENT) <= 1.8
def test_the_buyer_mark_shows_on_both_steps_of_the_funnel():
"""Один шаг дал бы половину картины: кладут реже и бросают чаще оба."""
assert world.BUYER_CART_PERCENT > world.CART_PERCENT
assert world.BUYER_CHECKOUT_OF_CART_PERCENT > world.CHECKOUT_OF_CART_PERCENT
lift = conversion(
world.BUYER_CART_PERCENT, world.BUYER_CHECKOUT_OF_CART_PERCENT
) / conversion(world.CART_PERCENT, world.CHECKOUT_OF_CART_PERCENT)
# Заметно чаще прочих, но не «покупают только помеченные»: 5% людей дали
# бы тогда около 40 заказов в день вместо 240 (спека, раздел 9).
assert 2 <= lift <= 6
def test_the_buyer_mark_makes_the_cookie_live_longer():
"""Второй рычаг метки: без долгой жизни второй покупке негде случиться."""
assert world.BUYER_ONE_SHOT_PERCENT < world.ONE_SHOT_PERCENT
usual = active_days(world.ONE_SHOT_PERCENT, world.RETURN_COUNT_WEIGHTS)
buyer = active_days(world.BUYER_ONE_SHOT_PERCENT, world.BUYER_RETURN_COUNT_WEIGHTS)
assert buyer > 2 * usual
# Но не бессмертие: дневная аудитория остаётся в вилке 6–8 тыс., а
# помеченных всего 5% людей когорты.
assert buyer < world.RETURN_TAIL_DAYS / 10