feat(generator): заказы дня — проекция покупок с деньгами магазина #98
@@ -49,8 +49,10 @@
|
|||||||
|
|
||||||
## Открытые решения
|
## Открытые решения
|
||||||
|
|
||||||
- **Имена подпотоков сторон судьбы** — при реализации; из мёртвых имён никто
|
- **Имя подпотока событийной стороны** — вместе с её содержимым на этапе 6;
|
||||||
не бросает, переименование ничего не сдвигает (#72).
|
из мёртвых имён никто не бросает, переименование ничего не сдвигает (#72).
|
||||||
|
Заказная сторона уже названа — подпоток `ORDERS` ([судьба
|
||||||
|
заказа](fate.md)).
|
||||||
- **Конкретные веса и доли** — таблицы исходов, моментов, задержки опоздания,
|
- **Конкретные веса и доли** — таблицы исходов, моментов, задержки опоздания,
|
||||||
доли классов, стоимость доставки — калибровка при реализации; финальная
|
доли классов, стоимость доставки — калибровка при реализации; финальная
|
||||||
фиксация чисел — пересборка эталонного мира, этап 7. При пересборке правки
|
фиксация чисел — пересборка эталонного мира, этап 7. При пересборке правки
|
||||||
|
|||||||
@@ -14,10 +14,13 @@
|
|||||||
не двигает хеши событий, правка событийной не двигает байты слепка, и по
|
не двигает хеши событий, правка событийной не двигает байты слепка, и по
|
||||||
покрасневшим хешам видно, какую сторону трогали. Прежние имена `DISCREPANCIES`
|
покрасневшим хешам видно, какую сторону трогали. Прежние имена `DISCREPANCIES`
|
||||||
и `LATECOMERS` решения не переживают — они названы по классам витрины, а
|
и `LATECOMERS` решения не переживают — они названы по классам витрины, а
|
||||||
компонент называет часть мира; новые стороны занимают те же позиции, имена —
|
компонент называет часть мира; новые стороны занимают те же позиции.
|
||||||
при реализации. Отклонено: *один компонент на всю судьбу* — правка событийной
|
Заказная сторона названа при реализации — подпоток `ORDERS` на позиции 2;
|
||||||
механики молча меняла бы байты слепка; *компонент на класс* — пять имён под
|
событийная получит имя вместе с содержимым на этапе 6, до тех пор позиция 3
|
||||||
ручки калибровки, которые крутятся разом.
|
занята мёртвым именем прежней нарезки. Отклонено: *один компонент на всю
|
||||||
|
судьбу* — правка событийной механики молча меняла бы байты слепка;
|
||||||
|
*компонент на класс* — пять имён под ручки калибровки, которые крутятся
|
||||||
|
разом.
|
||||||
|
|
||||||
**Правило формы, без которого разделение не работает: броски заказной стороны
|
**Правило формы, без которого разделение не работает: броски заказной стороны
|
||||||
делаются на полную длину дня, а не на отобранных заказах.** Иначе длина броска
|
делаются на полную длину дня, а не на отобранных заказах.** Иначе длина броска
|
||||||
|
|||||||
@@ -28,6 +28,14 @@
|
|||||||
В модели строка заказа в базе источника создаётся синхронно с покупкой,
|
В модели строка заказа в базе источника создаётся синхронно с покупкой,
|
||||||
поэтому день рождения заказа и день создания строки совпадают.
|
поэтому день рождения заказа и день создания строки совпадают.
|
||||||
|
|
||||||
|
**Деньги заказа** складываются целыми копейками: `items_total` — сумма
|
||||||
|
позиций, посчитанная торговой половиной, то же число, что уехало клиентским
|
||||||
|
`purchaseRevenue`; `discount` — процент промокода от неё, округлённый вниз;
|
||||||
|
`delivery` — бросок заказной стороны по таблице целых весов, единственные
|
||||||
|
деньги заказа, которых нет ни в одном событии; `total` = `items_total` −
|
||||||
|
`discount` + `delivery`. Отсюда и правило витрин «деньги считаем по
|
||||||
|
бэкенду»: про скидку и доставку клиент не знает вовсе.
|
||||||
|
|
||||||
Отклонено: *выводить заказ разбором собственного вывода* (`purchaseID`, сырой
|
Отклонено: *выводить заказ разбором собственного вывода* (`purchaseID`, сырой
|
||||||
`ecommerce`) — бэкенд стал бы читателем трекера ровно там, где стенд учит, что
|
`ecommerce`) — бэкенд стал бы читателем трекера ровно там, где стенд учит, что
|
||||||
это разные источники; *независимая модель бэкенда* (заказ первичен, событие —
|
это разные источники; *независимая модель бэкенда* (заказ первичен, событие —
|
||||||
|
|||||||
+12
-6
@@ -7,8 +7,9 @@
|
|||||||
События уже есть и уже уезжают: день-функция отдаёт по паре (зерно, D)
|
События уже есть и уже уезжают: день-функция отдаёт по паре (зерно, D)
|
||||||
упорядоченный поток трёх видов — просмотр страницы, корзина, покупка, —
|
упорядоченный поток трёх видов — просмотр страницы, корзина, покупка, —
|
||||||
канонический сериализатор превращает его в JSON, а проигрыватель гонит в файл
|
канонический сериализатор превращает его в JSON, а проигрыватель гонит в файл
|
||||||
или в Kafka. Клиентская сторона на этом целая; заказы бэкенда — за следующими
|
или в Kafka. Клиентская сторона на этом целая. Второй источник строится:
|
||||||
этапами.
|
день отдаёт и заказы бэкенда — проекцию своих покупок с деньгами магазина, —
|
||||||
|
а судьба заказа и отправка слепка идут следующими тикетами.
|
||||||
|
|
||||||
## Как это работает
|
## Как это работает
|
||||||
|
|
||||||
@@ -30,7 +31,8 @@ D0 живёт предыстория, поэтому любой день соб
|
|||||||
значит сдвинуть все броски после него, а с ними и весь мир: события того же
|
значит сдвинуть все броски после него, а с ними и весь мир: события того же
|
||||||
дня станут другими, счётчики канонического мира разойдутся с описью, и
|
дня станут другими, счётчики канонического мира разойдутся с описью, и
|
||||||
поймается это не ошибкой, а красным чеком. Ровно поэтому паспорта кук в
|
поймается это не ошибкой, а красным чеком. Ровно поэтому паспорта кук в
|
||||||
`plan.cohort` бросаются последними.
|
`plan.cohort` брошены последними, а личность человека приписана уже за ними:
|
||||||
|
куки, пары и паспорта остались теми же до байта.
|
||||||
|
|
||||||
Случайность целочисленная — только диапазоны и выбор по целым весам: готовые
|
Случайность целочисленная — только диапазоны и выбор по целым весам: готовые
|
||||||
распределения numpy расходятся между версиями и архитектурами, а обещано
|
распределения numpy расходятся между версиями и архитектурами, а обещано
|
||||||
@@ -44,8 +46,8 @@ D0 живёт предыстория, поэтому любой день соб
|
|||||||
- `src/clickstream_generator/seeds.py` — иерархия зёрен: кто из какого
|
- `src/clickstream_generator/seeds.py` — иерархия зёрен: кто из какого
|
||||||
подпотока берёт случайность. На ней держится весь детерминизм.
|
подпотока берёт случайность. На ней держится весь детерминизм.
|
||||||
- `src/clickstream_generator/plan.py` — план состава: кто есть в мире в
|
- `src/clickstream_generator/plan.py` — план состава: кто есть в мире в
|
||||||
день D. Когорты, приток, двухкуковые пары, паспорта кук и счётчики — до
|
день D. Когорты, приток, двухкуковые пары, паспорта кук, человек за кукой
|
||||||
генерации событий.
|
и счётчики — до генерации событий.
|
||||||
- `src/clickstream_generator/weights.py` — выбор по целым весам: один приём
|
- `src/clickstream_generator/weights.py` — выбор по целым весам: один приём
|
||||||
на весь генератор, чтобы дисциплина целочисленной случайности не жила
|
на весь генератор, чтобы дисциплина целочисленной случайности не жила
|
||||||
копиями.
|
копиями.
|
||||||
@@ -56,10 +58,14 @@ D0 живёт предыстория, поэтому любой день соб
|
|||||||
`data/catalog/products.csv`, общего у генератора и словаря ClickHouse.
|
`data/catalog/products.csv`, общего у генератора и словаря ClickHouse.
|
||||||
- `src/clickstream_generator/day.py` — день-функция: визиты, страницы,
|
- `src/clickstream_generator/day.py` — день-функция: визиты, страницы,
|
||||||
атрибуция, устройство и гео. Там же правила резки визитов и шов, на
|
атрибуция, устройство и гео. Там же правила резки визитов и шов, на
|
||||||
который садятся торговые события.
|
который садятся торговые события; прожитый день отдаёт обе половины —
|
||||||
|
поток событий и заказы.
|
||||||
- `src/clickstream_generator/commerce.py` — торговые события: что легло в
|
- `src/clickstream_generator/commerce.py` — торговые события: что легло в
|
||||||
корзину, что из этого куплено, деньги, номера заказов и сырой `ecommerce`.
|
корзину, что из этого куплено, деньги, номера заказов и сырой `ecommerce`.
|
||||||
Своя случайность, поэтому правка торговли трафик не двигает.
|
Своя случайность, поэтому правка торговли трафик не двигает.
|
||||||
|
- `src/clickstream_generator/orders.py` — заказы бэкенда: вторая проекция
|
||||||
|
покупки. Личность покупателя и деньги магазина — скидка по промокоду,
|
||||||
|
доставка, итог; своя случайность, ветвящаяся по дню рождения заказа.
|
||||||
- `src/clickstream_generator/ids.py` — номера событий: неповторяющиеся и
|
- `src/clickstream_generator/ids.py` — номера событий: неповторяющиеся и
|
||||||
ниже 2^53. Обещание одно на обе половины дня, поэтому и живёт отдельно.
|
ниже 2^53. Обещание одно на обе половины дня, поэтому и живёт отдельно.
|
||||||
- `src/clickstream_generator/serialize.py` — канонический сериализатор:
|
- `src/clickstream_generator/serialize.py` — канонический сериализатор:
|
||||||
|
|||||||
@@ -39,9 +39,17 @@
|
|||||||
дошедшие до потока дня, в порядке событий — до всяких потерь; поэтому номер
|
дошедшие до потока дня, в порядке событий — до всяких потерь; поэтому номер
|
||||||
присваивается последним ходом, когда поток уже упорядочен.
|
присваивается последним ходом, когда поток уже упорядочен.
|
||||||
|
|
||||||
|
**Второй выход — покупки дня.** Кроме потока событий торговая половина
|
||||||
|
отдаёт покупки готовой структурой: номер заказа, корзина, деньги клиента,
|
||||||
|
промокод и человек за кукой. Заказная сторона берёт их такими и ничего не
|
||||||
|
пересчитывает: заказ — вторая проекция той же покупки, поэтому согласие двух
|
||||||
|
источников не удерживается, а выходит по построению
|
||||||
|
(docs/architecture/orders/snapshot.md).
|
||||||
|
|
||||||
**Случайность — подпоток `COMMERCE`** (спека генератора, раздел 2): правка
|
**Случайность — подпоток `COMMERCE`** (спека генератора, раздел 2): правка
|
||||||
торгового поведения не сдвигает трафиковый поток. Броски целые и векторные;
|
торгового поведения не сдвигает трафиковый поток. Броски целые и векторные;
|
||||||
посточно собираются только строки — их numpy не умеет.
|
посточно собираются только строки — их numpy не умеет. Новых бросков заказы
|
||||||
|
сюда не добавляют: свои решения заказная сторона тянет из своего подпотока.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -123,19 +131,61 @@ class _Draws:
|
|||||||
order_delay: NDArray[np.int64]
|
order_delay: NDArray[np.int64]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Purchases:
|
||||||
|
"""Покупки дня готовой структурой — второй выход торговой половины.
|
||||||
|
|
||||||
|
Ряды одной длины, по элементу на покупку, в порядке номеров заказа.
|
||||||
|
Корзина лежит парой рядов ячеек — номера товаров каталога и штуки; в
|
||||||
|
каждой ячейке свой массив, длиной в число позиций покупки.
|
||||||
|
"""
|
||||||
|
|
||||||
|
order_id: tuple[str, ...]
|
||||||
|
# Человек за кукой, которая купила: у заказа он назовётся `user_id`.
|
||||||
|
person_id: NDArray[np.uint64]
|
||||||
|
product: tuple[NDArray[np.int64], ...]
|
||||||
|
quantity: tuple[NDArray[np.int64], ...]
|
||||||
|
# Промокод события строкой; у покупки без кода — пустая.
|
||||||
|
coupon: tuple[str, ...]
|
||||||
|
# Деньги клиента целыми копейками: сумма позиций без скидки и доставки,
|
||||||
|
# та самая, что уехала в событие как `purchaseRevenue`. Бэкенд назовёт
|
||||||
|
# её `items_total` — у двух источников свои имена одному числу.
|
||||||
|
revenue: NDArray[np.int64]
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return self.person_id.size
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class _Events:
|
class _Events:
|
||||||
"""Строки одного вида торговых событий, готовые встать в поток.
|
"""Строки одного вида торговых событий, готовые встать в поток.
|
||||||
|
|
||||||
`raw` — сырой `ecommerce` каждой строки ещё объектом: номер заказа в нём
|
`raw` — сырой `ecommerce` каждой строки ещё объектом: номер заказа в нём
|
||||||
появится, когда поток будет упорядочен, а строка станет байтами один
|
появится, когда поток будет упорядочен, а строка станет байтами один
|
||||||
раз, каноническим сериализатором.
|
раз, каноническим сериализатором. `basket` говорит, чья корзина стоит за
|
||||||
|
строкой: по нему покупка находит свою после того, как поток упорядочен.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
columns: dict[str, NDArray[Any]]
|
columns: dict[str, NDArray[Any]]
|
||||||
page: NDArray[np.uint8]
|
page: NDArray[np.uint8]
|
||||||
product: NDArray[np.int64]
|
product: NDArray[np.int64]
|
||||||
raw: list[dict[str, Any]]
|
raw: list[dict[str, Any]]
|
||||||
|
basket: NDArray[np.int64]
|
||||||
|
|
||||||
|
|
||||||
|
# Что торговая половина отдаёт дню: поток целиком — колонки, страницы и
|
||||||
|
# товары — и покупки дня вторым выходом.
|
||||||
|
Woven = tuple[dict[str, NDArray[Any]], NDArray[np.uint8], NDArray[np.int64], Purchases]
|
||||||
|
|
||||||
|
# День без единой корзины: покупок в нём нет, а форма у рядов есть.
|
||||||
|
_NO_PURCHASES = Purchases(
|
||||||
|
order_id=(),
|
||||||
|
person_id=np.empty(0, dtype=np.uint64),
|
||||||
|
product=(),
|
||||||
|
quantity=(),
|
||||||
|
coupon=(),
|
||||||
|
revenue=np.empty(0, dtype=np.int64),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def weave(
|
def weave(
|
||||||
@@ -144,24 +194,34 @@ def weave(
|
|||||||
columns: dict[str, NDArray[Any]],
|
columns: dict[str, NDArray[Any]],
|
||||||
page: NDArray[np.uint8],
|
page: NDArray[np.uint8],
|
||||||
product: NDArray[np.int64],
|
product: NDArray[np.int64],
|
||||||
) -> tuple[dict[str, NDArray[Any]], NDArray[np.uint8], NDArray[np.int64]]:
|
person: NDArray[np.uint64],
|
||||||
"""Вплетает торговые события в трафиковый поток и отдаёт поток целиком.
|
) -> Woven:
|
||||||
|
"""Вплетает торговые события в поток и отдаёт поток и покупки дня.
|
||||||
|
|
||||||
Строки приходят упорядоченными по времени и такими же уходят: торговые
|
Строки приходят упорядоченными по времени и такими же уходят: торговые
|
||||||
события встают между ними, и поток пересобирается одним порядком.
|
события встают между ними, и поток пересобирается одним порядком. Второй
|
||||||
|
выход — покупки дня: то же самое, чем они уехали в события.
|
||||||
|
|
||||||
|
`person` — человек за кукой каждой строки, выровненный по входящему
|
||||||
|
потоку: личность приходит от плана состава, а не выводится из трекера
|
||||||
|
(docs/architecture/orders/identity.md).
|
||||||
"""
|
"""
|
||||||
rng = day_stream(seed, day, Component.COMMERCE)
|
rng = day_stream(seed, day, Component.COMMERCE)
|
||||||
baskets = _baskets(rng, page, product, columns["VisitID"])
|
baskets = _baskets(rng, page, product, columns["VisitID"])
|
||||||
if not len(baskets):
|
if not len(baskets):
|
||||||
return columns, page, product
|
return columns, page, product, _NO_PURCHASES
|
||||||
|
|
||||||
goods = catalog.catalog()
|
goods = catalog.catalog()
|
||||||
draws = _draws(rng, baskets)
|
draws = _draws(rng, baskets)
|
||||||
|
revenue = _revenue(goods, baskets, draws)
|
||||||
|
coupons = _coupon_codes(draws)
|
||||||
events = (
|
events = (
|
||||||
_cart_events(columns, baskets, goods, draws),
|
_cart_events(columns, baskets, goods, draws),
|
||||||
_order_events(columns, baskets, goods, draws),
|
_order_events(columns, baskets, goods, draws, revenue, coupons),
|
||||||
)
|
)
|
||||||
return _stream(rng, columns, page, product, events)
|
columns, page, product, basket = _stream(rng, columns, page, product, events)
|
||||||
|
purchases = _purchases(columns, basket, baskets, draws, revenue, coupons, person)
|
||||||
|
return columns, page, product, purchases
|
||||||
|
|
||||||
|
|
||||||
def _draws(rng: np.random.Generator, baskets: _Baskets) -> _Draws:
|
def _draws(rng: np.random.Generator, baskets: _Baskets) -> _Draws:
|
||||||
@@ -307,6 +367,36 @@ def _coupons(rng: np.random.Generator, baskets: int) -> NDArray[np.int64]:
|
|||||||
return np.where(rng.integers(0, 100, baskets) < world.COUPON_PERCENT, code, -1)
|
return np.where(rng.integers(0, 100, baskets) < world.COUPON_PERCENT, code, -1)
|
||||||
|
|
||||||
|
|
||||||
|
def _coupon_codes(draws: _Draws) -> NDArray[np.object_]:
|
||||||
|
"""Промокод каждой корзины строкой; у корзины без кода — пустая.
|
||||||
|
|
||||||
|
Код нужен обеим сторонам: событие везёт его как есть, а бэкенд по нему
|
||||||
|
считает скидку заказа — по той же таблице чисел мира.
|
||||||
|
"""
|
||||||
|
return np.array(
|
||||||
|
[
|
||||||
|
world.COUPONS[number][0] if number >= 0 else ""
|
||||||
|
for number in draws.coupon.tolist()
|
||||||
|
],
|
||||||
|
dtype=object,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _revenue(
|
||||||
|
goods: catalog.Catalog, baskets: _Baskets, draws: _Draws
|
||||||
|
) -> NDArray[np.int64]:
|
||||||
|
"""Деньги клиента по корзинам, целыми копейками: сумма купленных позиций.
|
||||||
|
|
||||||
|
Сумма считается один раз на день и уезжает в оба источника: в событие —
|
||||||
|
как `purchaseRevenue`, в заказ — как `items_total`. Разойтись им негде,
|
||||||
|
и это не совпадение, а построение.
|
||||||
|
"""
|
||||||
|
value = goods.price[baskets.product] * draws.quantity
|
||||||
|
kopecks = np.zeros(len(baskets), dtype=np.int64)
|
||||||
|
np.add.at(kopecks, baskets.basket[draws.kept], value[draws.kept])
|
||||||
|
return kopecks
|
||||||
|
|
||||||
|
|
||||||
def _cart_events(
|
def _cart_events(
|
||||||
columns: dict[str, NDArray[Any]],
|
columns: dict[str, NDArray[Any]],
|
||||||
baskets: _Baskets,
|
baskets: _Baskets,
|
||||||
@@ -329,6 +419,7 @@ def _cart_events(
|
|||||||
page=np.full(positions, Page.PRODUCT, dtype=np.uint8),
|
page=np.full(positions, Page.PRODUCT, dtype=np.uint8),
|
||||||
product=baskets.product.copy(),
|
product=baskets.product.copy(),
|
||||||
raw=[{"currencyCode": world.CURRENCY, ADD_ACTION: block} for block in blocks],
|
raw=[{"currencyCode": world.CURRENCY, ADD_ACTION: block} for block in blocks],
|
||||||
|
basket=baskets.basket,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -337,6 +428,8 @@ def _order_events(
|
|||||||
baskets: _Baskets,
|
baskets: _Baskets,
|
||||||
goods: catalog.Catalog,
|
goods: catalog.Catalog,
|
||||||
draws: _Draws,
|
draws: _Draws,
|
||||||
|
revenue: NDArray[np.int64],
|
||||||
|
coupons: NDArray[np.object_],
|
||||||
) -> _Events:
|
) -> _Events:
|
||||||
"""Строки `purchase`: по одной на корзину, дошедшую до подтверждения."""
|
"""Строки `purchase`: по одной на корзину, дошедшую до подтверждения."""
|
||||||
ordered = np.flatnonzero(baskets.confirmation >= 0)
|
ordered = np.flatnonzero(baskets.confirmation >= 0)
|
||||||
@@ -352,14 +445,9 @@ def _order_events(
|
|||||||
rows.update(side)
|
rows.update(side)
|
||||||
|
|
||||||
# Выручка клиента — сумма позиций без скидки и доставки, целыми копейками.
|
# Выручка клиента — сумма позиций без скидки и доставки, целыми копейками.
|
||||||
kopecks = [
|
# Считана она один раз на день: тем же числом её возьмёт заказ бэкенда.
|
||||||
int((goods.price[baskets.product[group]] * draws.quantity[group]).sum())
|
kopecks = revenue[ordered].tolist()
|
||||||
for group in bought
|
codes = coupons[ordered].tolist()
|
||||||
]
|
|
||||||
codes = [
|
|
||||||
world.COUPONS[number][0] if number >= 0 else ""
|
|
||||||
for number in draws.coupon[ordered].tolist()
|
|
||||||
]
|
|
||||||
rows["purchaseRevenue"] = _cells(
|
rows["purchaseRevenue"] = _cells(
|
||||||
[np.array([money / KOPECKS], dtype=np.float64) for money in kopecks]
|
[np.array([money / KOPECKS], dtype=np.float64) for money in kopecks]
|
||||||
)
|
)
|
||||||
@@ -380,6 +468,7 @@ def _order_events(
|
|||||||
page=np.full(ordered.size, Page.CONFIRMATION, dtype=np.uint8),
|
page=np.full(ordered.size, Page.CONFIRMATION, dtype=np.uint8),
|
||||||
product=np.full(ordered.size, -1, dtype=np.int64),
|
product=np.full(ordered.size, -1, dtype=np.int64),
|
||||||
raw=raw,
|
raw=raw,
|
||||||
|
basket=ordered,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -479,8 +568,15 @@ def _stream(
|
|||||||
page: NDArray[np.uint8],
|
page: NDArray[np.uint8],
|
||||||
product: NDArray[np.int64],
|
product: NDArray[np.int64],
|
||||||
events: tuple[_Events, ...],
|
events: tuple[_Events, ...],
|
||||||
) -> tuple[dict[str, NDArray[Any]], NDArray[np.uint8], NDArray[np.int64]]:
|
) -> tuple[
|
||||||
"""Собирает поток дня целиком: сутки режут хвост, время задаёт порядок."""
|
dict[str, NDArray[Any]], NDArray[np.uint8], NDArray[np.int64], NDArray[np.int64]
|
||||||
|
]:
|
||||||
|
"""Собирает поток дня целиком: сутки режут хвост, время задаёт порядок.
|
||||||
|
|
||||||
|
Четвёртым рядом уходит корзина каждой строки — у просмотра страницы её
|
||||||
|
нет: по ней покупка находит свою корзину, когда поток уже упорядочен и
|
||||||
|
полночь свой хвост отрезала.
|
||||||
|
"""
|
||||||
traffic = page.size
|
traffic = page.size
|
||||||
trade = {
|
trade = {
|
||||||
name: np.concatenate([part.columns[name] for part in events])
|
name: np.concatenate([part.columns[name] for part in events])
|
||||||
@@ -506,10 +602,17 @@ def _stream(
|
|||||||
place = np.full(traffic + len(raw), -1, dtype=np.int64)
|
place = np.full(traffic + len(raw), -1, dtype=np.int64)
|
||||||
place[traffic:] = np.arange(len(raw))
|
place[traffic:] = np.arange(len(raw))
|
||||||
|
|
||||||
|
basket = np.concatenate(
|
||||||
|
(
|
||||||
|
np.full(traffic, -1, dtype=np.int64),
|
||||||
|
np.concatenate([part.basket for part in events])[alive],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
order = np.lexsort((rows["WatchID"], rows["UTCEventTime"]))
|
order = np.lexsort((rows["WatchID"], rows["UTCEventTime"]))
|
||||||
rows = {name: value[order] for name, value in rows.items()}
|
rows = {name: value[order] for name, value in rows.items()}
|
||||||
_seal(rows, raw, place[order], _order_prefix(columns))
|
_seal(rows, raw, place[order], _order_prefix(columns))
|
||||||
return rows, page[order], product[order]
|
return rows, page[order], product[order], basket[order]
|
||||||
|
|
||||||
|
|
||||||
def _seal(
|
def _seal(
|
||||||
@@ -537,6 +640,35 @@ def _seal(
|
|||||||
rows["ecommerce"][row] = orjson.dumps(block).decode()
|
rows["ecommerce"][row] = orjson.dumps(block).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def _purchases(
|
||||||
|
rows: dict[str, NDArray[Any]],
|
||||||
|
basket: NDArray[np.int64],
|
||||||
|
baskets: _Baskets,
|
||||||
|
draws: _Draws,
|
||||||
|
revenue: NDArray[np.int64],
|
||||||
|
coupons: NDArray[np.object_],
|
||||||
|
person: NDArray[np.uint64],
|
||||||
|
) -> Purchases:
|
||||||
|
"""Покупки дня — то же, чем они уехали в события, только структурой.
|
||||||
|
|
||||||
|
Порядок здесь — порядок строк потока, он же порядок номеров заказа:
|
||||||
|
поток уже упорядочен и пронумерован, полночь свой хвост уже отрезала.
|
||||||
|
Человек берётся у строки подтверждения — визит принадлежит одной куке,
|
||||||
|
а кука одному человеку.
|
||||||
|
"""
|
||||||
|
here = np.flatnonzero(rows["EventType"] == PURCHASE)
|
||||||
|
mine = basket[here]
|
||||||
|
bought = [baskets.positions_of(number, draws.kept) for number in mine.tolist()]
|
||||||
|
return Purchases(
|
||||||
|
order_id=tuple(rows["purchaseID"][row][0] for row in here.tolist()),
|
||||||
|
person_id=person[baskets.confirmation[mine]],
|
||||||
|
product=tuple(baskets.product[group] for group in bought),
|
||||||
|
quantity=tuple(draws.quantity[group] for group in bought),
|
||||||
|
coupon=tuple(coupons[mine].tolist()),
|
||||||
|
revenue=revenue[mine],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _midnight(columns: dict[str, NDArray[Any]]) -> np.datetime64:
|
def _midnight(columns: dict[str, NDArray[Any]]) -> np.datetime64:
|
||||||
"""Начало модельных суток абсолютной меткой: полночь в поясе счётчика."""
|
"""Начало модельных суток абсолютной меткой: полночь в поясе счётчика."""
|
||||||
date: np.datetime64 = columns["EventDate"][0]
|
date: np.datetime64 = columns["EventDate"][0]
|
||||||
|
|||||||
@@ -36,15 +36,21 @@
|
|||||||
гарантия, ради неё мы сужаем свободу старта. Цена названа — около 24
|
гарантия, ради неё мы сужаем свободу старта. Цена названа — около 24
|
||||||
визитов в день из ~9,5 тыс. не начинаются в последние минуты суток.
|
визитов в день из ~9,5 тыс. не начинаются в последние минуты суток.
|
||||||
|
|
||||||
**Шов с торговыми событиями.** Поток несёт, кроме колонок, два выровненных
|
**Шов с торговыми событиями.** Поток несёт, кроме колонок, три выровненных
|
||||||
по строкам ряда: `page` — какая это страница магазина, и `product` — какой
|
по строкам ряда: `page` — какая это страница магазина, `product` — какой
|
||||||
товар показывала карточка (−1 у прочих страниц). По ним `commerce` знает и
|
товар показывала карточка (−1 у прочих страниц), и `person` — человек за
|
||||||
то, куда сажать событие (карточка, подтверждение), и то, что посетитель на
|
кукой этой строки. По первым двум `commerce` знает и то, куда сажать событие
|
||||||
самом деле смотрел: товар в корзине, которого никто не открывал, — видимая
|
(карточка, подтверждение), и то, что посетитель на самом деле смотрел: товар
|
||||||
глупость в воронке. Визит с назначенным заказом всегда доходит до
|
в корзине, которого никто не открывал, — видимая глупость в воронке. Третий
|
||||||
`/confirmation`, а перед корзиной у него всегда есть карточка товара.
|
в события не попадает вовсе: личность нужна заказу, а кликстрим анонимен.
|
||||||
Случайность у половин разная: трафик берёт подпоток `TRAFFIC`, торговля —
|
Визит с назначенным заказом всегда доходит до `/confirmation`, а перед
|
||||||
`COMMERCE`, и правка одной не сдвигает другую.
|
корзиной у него всегда есть карточка товара. Случайность у половин разная:
|
||||||
|
трафик берёт подпоток `TRAFFIC`, торговля — `COMMERCE`, заказы — свой, и
|
||||||
|
правка одной не сдвигает другие.
|
||||||
|
|
||||||
|
**Заказы бэкенда** день отдаёт второй половиной: покупки, которые собрала
|
||||||
|
торговая половина, заказная превращает в заказы дня — с личностью
|
||||||
|
покупателя и деньгами магазина (`orders`).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -53,7 +59,7 @@ from typing import Any
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
from numpy.typing import NDArray
|
from numpy.typing import NDArray
|
||||||
|
|
||||||
from clickstream_generator import catalog, commerce, ids, plan, reference, world
|
from clickstream_generator import catalog, commerce, ids, orders, plan, reference, world
|
||||||
from clickstream_generator.reference import Page
|
from clickstream_generator.reference import Page
|
||||||
from clickstream_generator.seeds import Component, day_stream
|
from clickstream_generator.seeds import Component, day_stream
|
||||||
from clickstream_generator.weights import pick, pick_row
|
from clickstream_generator.weights import pick, pick_row
|
||||||
@@ -78,18 +84,22 @@ _SOURCE_CUMULATIVE = np.cumsum([source.weight for source in reference.TRAFFIC_SO
|
|||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class Day:
|
class Day:
|
||||||
"""Поток событий одного дня: колонки выгрузки и страницы за ними.
|
"""Прожитый день: поток событий трекера и заказы бэкенда.
|
||||||
|
|
||||||
Строки упорядочены по времени — так их и проиграет проигрыватель.
|
Строки упорядочены по времени — так их и проиграет проигрыватель.
|
||||||
`columns` — колонки контракта схемы по его порядку, все до одной;
|
`columns` — колонки контракта схемы по его порядку, все до одной;
|
||||||
`page` и `product` выровнены по тем же строкам (см. шов в докстринге
|
`page` и `product` выровнены по тем же строкам (см. шов в докстринге
|
||||||
модуля).
|
модуля). `orders` — вторая половина дня: заказы, рождённые в нём.
|
||||||
|
|
||||||
|
Половины уезжают разными командами и в разные топики, но считаются
|
||||||
|
вместе: заказ — проекция покупки, и порознь им разойтись негде.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
day: int
|
day: int
|
||||||
columns: dict[str, NDArray[Any]]
|
columns: dict[str, NDArray[Any]]
|
||||||
page: NDArray[np.uint8]
|
page: NDArray[np.uint8]
|
||||||
product: NDArray[np.int64]
|
product: NDArray[np.int64]
|
||||||
|
orders: orders.Orders
|
||||||
|
|
||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
return self.page.size
|
return self.page.size
|
||||||
@@ -149,15 +159,24 @@ def stream(seed: int, day: int) -> Day:
|
|||||||
|
|
||||||
order = np.lexsort((columns["WatchID"], columns["UTCEventTime"]))
|
order = np.lexsort((columns["WatchID"], columns["UTCEventTime"]))
|
||||||
# Торговые события садятся на готовый трафиковый поток и отдают его
|
# Торговые события садятся на готовый трафиковый поток и отдают его
|
||||||
# целиком: в нём же они и упорядочиваются.
|
# целиком: в нём же они и упорядочиваются. Вторым выходом приходят
|
||||||
columns, page, product = commerce.weave(
|
# покупки дня — из них заказная сторона собирает заказы.
|
||||||
|
person = np.repeat(audience.person_id[visits.cookie], visits.pages)
|
||||||
|
columns, page, product, purchases = commerce.weave(
|
||||||
seed,
|
seed,
|
||||||
day,
|
day,
|
||||||
{name: value[order] for name, value in columns.items()},
|
{name: value[order] for name, value in columns.items()},
|
||||||
page[alive][order],
|
page[alive][order],
|
||||||
product[alive][order],
|
product[alive][order],
|
||||||
|
person[alive][order],
|
||||||
|
)
|
||||||
|
return Day(
|
||||||
|
day=day,
|
||||||
|
columns=columns,
|
||||||
|
page=page,
|
||||||
|
product=product,
|
||||||
|
orders=orders.of_day(seed, day, purchases),
|
||||||
)
|
)
|
||||||
return Day(day=day, columns=columns, page=page, product=product)
|
|
||||||
|
|
||||||
|
|
||||||
def _visits(rng: np.random.Generator, audience: plan.DayAudience) -> _Visits:
|
def _visits(rng: np.random.Generator, audience: plan.DayAudience) -> _Visits:
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""Заказы бэкенда: вторая проекция покупки и деньги магазина.
|
||||||
|
|
||||||
|
Заказ — не второе порождение, а вторая проекция того же факта мира. Корзину,
|
||||||
|
цены, промокод и номер посчитала торговая половина дня-функции; заказная
|
||||||
|
половина берёт её покупки готовой структурой и добавляет то, чего у клиента
|
||||||
|
нет: личность покупателя под родным именем `user_id` и деньги магазина —
|
||||||
|
скидку по коду, доставку и итог. Новых бросков в торговый подпоток заказная
|
||||||
|
половина не делает, поэтому два источника согласованы по построению, а не
|
||||||
|
сверкой (docs/architecture/orders/snapshot.md).
|
||||||
|
|
||||||
|
**Деньги — целыми копейками**, как и везде в генераторе: `items_total` —
|
||||||
|
сумма позиций, посчитанная торговой половиной (у клиента то же число зовётся
|
||||||
|
выручкой); `discount` — скидка по промокоду события, по таблице «код →
|
||||||
|
скидка» из чисел мира; `delivery` — единственные деньги заказа, которых нет
|
||||||
|
ни в одном событии; `total` — `items_total` − `discount` + `delivery`.
|
||||||
|
Отсюда правило витрин «деньги считаем по бэкенду»: про скидку и доставку
|
||||||
|
клиент не знает вовсе.
|
||||||
|
|
||||||
|
**Случайность — подпоток заказной стороны**, ветвящийся по дню рождения
|
||||||
|
заказа: слепок несёт семь дней рождения сразу и судьбу каждого заказа обязан
|
||||||
|
читать из его собственного дня. Броски делаются на полную длину дня, а не на
|
||||||
|
отобранных заказах, — иначе длина броска стала бы функцией доли, и правка
|
||||||
|
одной доли перебрасывала бы весь подпоток после себя
|
||||||
|
(docs/architecture/orders/fate.md).
|
||||||
|
|
||||||
|
Судьбы у заказа здесь ещё нет: статус, моменты оплаты и отмены и дельта
|
||||||
|
суммы — следующий тикет.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from numpy.typing import NDArray
|
||||||
|
|
||||||
|
from clickstream_generator import world
|
||||||
|
from clickstream_generator.commerce import Purchases
|
||||||
|
from clickstream_generator.seeds import Component, day_stream
|
||||||
|
from clickstream_generator.weights import pick
|
||||||
|
|
||||||
|
_DISCOUNT_PERCENT = dict(world.COUPONS)
|
||||||
|
_DELIVERY_PRICE = np.array(
|
||||||
|
[price for price, _ in world.DELIVERY_KOPECKS_WEIGHTS], dtype=np.int64
|
||||||
|
)
|
||||||
|
_DELIVERY_CUMULATIVE = np.cumsum(
|
||||||
|
[weight for _, weight in world.DELIVERY_KOPECKS_WEIGHTS]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Orders:
|
||||||
|
"""Заказы одного модельного дня: ряды одной длины, по элементу на заказ.
|
||||||
|
|
||||||
|
Порядок — порядок рождения заказов, он же возрастание `order_id`. День
|
||||||
|
рождения заказа — `day`: строка в базе источника создаётся синхронно с
|
||||||
|
покупкой, поэтому день создания строки и день покупки совпадают.
|
||||||
|
"""
|
||||||
|
|
||||||
|
day: int
|
||||||
|
order_id: tuple[str, ...]
|
||||||
|
# Пользователь магазина: тот же человек, что стоит за купившей кукой.
|
||||||
|
user_id: NDArray[np.uint64]
|
||||||
|
# Позиции заказа: номера товаров каталога и штуки, ячейка на заказ.
|
||||||
|
product: tuple[NDArray[np.int64], ...]
|
||||||
|
quantity: tuple[NDArray[np.int64], ...]
|
||||||
|
# Деньги заказа, целые копейки.
|
||||||
|
items_total: NDArray[np.int64]
|
||||||
|
discount: NDArray[np.int64]
|
||||||
|
delivery: NDArray[np.int64]
|
||||||
|
total: NDArray[np.int64]
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return self.user_id.size
|
||||||
|
|
||||||
|
|
||||||
|
def of_day(seed: int, day: int, purchases: Purchases) -> Orders:
|
||||||
|
"""Заказы дня `day`: его покупки, к которым бэкенд добавил свои деньги."""
|
||||||
|
rng = day_stream(seed, day, Component.ORDERS)
|
||||||
|
delivery = _delivery(rng, len(purchases))
|
||||||
|
discount = _discount(purchases)
|
||||||
|
return Orders(
|
||||||
|
day=day,
|
||||||
|
order_id=purchases.order_id,
|
||||||
|
user_id=purchases.person_id,
|
||||||
|
product=purchases.product,
|
||||||
|
quantity=purchases.quantity,
|
||||||
|
items_total=purchases.revenue,
|
||||||
|
discount=discount,
|
||||||
|
delivery=delivery,
|
||||||
|
total=purchases.revenue - discount + delivery,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _delivery(rng: np.random.Generator, orders: int) -> NDArray[np.int64]:
|
||||||
|
"""Стоимость доставки каждого заказа дня — броском по таблице весов."""
|
||||||
|
return _DELIVERY_PRICE[pick(rng, _DELIVERY_CUMULATIVE, orders)]
|
||||||
|
|
||||||
|
|
||||||
|
def _discount(purchases: Purchases) -> NDArray[np.int64]:
|
||||||
|
"""Скидка каждого заказа: процент промокода от суммы позиций, вниз.
|
||||||
|
|
||||||
|
Броска здесь нет: код выбрал посетитель, и он уже уехал в событие —
|
||||||
|
бэкенду остаётся прочитать таблицу. Заказ без кода скидки не получает,
|
||||||
|
а спорную копейку округление оставляет магазину.
|
||||||
|
"""
|
||||||
|
percent = np.array(
|
||||||
|
[_DISCOUNT_PERCENT[code] if code else 0 for code in purchases.coupon],
|
||||||
|
dtype=np.int64,
|
||||||
|
)
|
||||||
|
return purchases.revenue * percent // 100
|
||||||
@@ -16,6 +16,9 @@
|
|||||||
каждая из двух кук обязана оформить заказ;
|
каждая из двух кук обязана оформить заказ;
|
||||||
- паспорт куки — устройство и город: они у куки одни и те же во всех её
|
- паспорт куки — устройство и город: они у куки одни и те же во всех её
|
||||||
днях, а знает об этом только план (у пары один город на двоих);
|
днях, а знает об этом только план (у пары один город на двоих);
|
||||||
|
- личность — `person_id` человека за кукой: у двух кук пары он один, и
|
||||||
|
заказ бэкенда показывает его как `user_id`
|
||||||
|
(docs/architecture/orders/identity.md);
|
||||||
- счётчики — приток по дням, дневная и накопленная аудитория, пары.
|
- счётчики — приток по дням, дневная и накопленная аудитория, пары.
|
||||||
|
|
||||||
Единица дня здесь — день активности куки, а не визит: слово «визит»
|
Единица дня здесь — день активности куки, а не визит: слово «визит»
|
||||||
@@ -92,6 +95,8 @@ class Cohort:
|
|||||||
# Паспорт куки: номер профиля устройства и номер города в справочниках.
|
# Паспорт куки: номер профиля устройства и номер города в справочниках.
|
||||||
device: NDArray[np.int64]
|
device: NDArray[np.int64]
|
||||||
city: NDArray[np.int64]
|
city: NDArray[np.int64]
|
||||||
|
# Человек за кукой: непрозрачный ID, одинаковый у двух кук пары.
|
||||||
|
person_id: NDArray[np.uint64]
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
"""Когорта запоминается, поэтому массивы отдаются только на чтение.
|
"""Когорта запоминается, поэтому массивы отдаются только на чтение.
|
||||||
@@ -132,6 +137,7 @@ class Cohort:
|
|||||||
assigned_order=np.isin(here, ordering),
|
assigned_order=np.isin(here, ordering),
|
||||||
device=self.device[here],
|
device=self.device[here],
|
||||||
city=self.city[here],
|
city=self.city[here],
|
||||||
|
person_id=self.person_id[here],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -147,6 +153,8 @@ class DayAudience:
|
|||||||
# Паспорт куки: номера строк в справочниках устройств и городов.
|
# Паспорт куки: номера строк в справочниках устройств и городов.
|
||||||
device: NDArray[np.int64]
|
device: NDArray[np.int64]
|
||||||
city: NDArray[np.int64]
|
city: NDArray[np.int64]
|
||||||
|
# Человек за кукой: его заказ покажет этот ID как `user_id`.
|
||||||
|
person_id: NDArray[np.uint64]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -200,8 +208,11 @@ def cohort(seed: int, day: int) -> Cohort:
|
|||||||
rng, twins, active_cookie, active_day, cookies
|
rng, twins, active_cookie, active_day, cookies
|
||||||
)
|
)
|
||||||
# Паспорт бросается последним — после всего, что уже измерено: тогда
|
# Паспорт бросается последним — после всего, что уже измерено: тогда
|
||||||
# счётчики канонического мира от этой добавки не двигаются.
|
# счётчики канонического мира от этой добавки не двигаются. По тому же
|
||||||
|
# правилу за ним приписана личность: этап 3 дописал её в конец, и куки,
|
||||||
|
# пары и паспорта остались теми же до байта.
|
||||||
device, city = _passports(rng, twins, cookies)
|
device, city = _passports(rng, twins, cookies)
|
||||||
|
person_id = _persons(rng, twins, people, cookies)
|
||||||
return Cohort(
|
return Cohort(
|
||||||
day=day,
|
day=day,
|
||||||
people=people,
|
people=people,
|
||||||
@@ -214,6 +225,7 @@ def cohort(seed: int, day: int) -> Cohort:
|
|||||||
pair_order_days=pair_order_days,
|
pair_order_days=pair_order_days,
|
||||||
device=device,
|
device=device,
|
||||||
city=city,
|
city=city,
|
||||||
|
person_id=person_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -234,6 +246,7 @@ def audience(seed: int, day: int) -> DayAudience:
|
|||||||
assigned_order=np.concatenate([part.assigned_order for part in parts]),
|
assigned_order=np.concatenate([part.assigned_order for part in parts]),
|
||||||
device=np.concatenate([part.device for part in parts]),
|
device=np.concatenate([part.device for part in parts]),
|
||||||
city=np.concatenate([part.city for part in parts]),
|
city=np.concatenate([part.city for part in parts]),
|
||||||
|
person_id=np.concatenate([part.person_id for part in parts]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -364,3 +377,22 @@ def _passports(
|
|||||||
pick(rng, _DEVICE_HALF_CUMULATIVE[half], here.size)
|
pick(rng, _DEVICE_HALF_CUMULATIVE[half], here.size)
|
||||||
]
|
]
|
||||||
return device, city
|
return device, city
|
||||||
|
|
||||||
|
|
||||||
|
def _persons(
|
||||||
|
rng: np.random.Generator, twins: NDArray[np.int64], people: int, cookies: int
|
||||||
|
) -> NDArray[np.uint64]:
|
||||||
|
"""Человек за каждой кукой: у двух кук пары ID один и тот же.
|
||||||
|
|
||||||
|
Личность — минимальная форма отношения «кука принадлежит человеку», и
|
||||||
|
знает его только план: заказ спрашивает и показывает то же число как
|
||||||
|
`user_id`, а в кликстрим оно не попадает вовсе
|
||||||
|
(docs/architecture/orders/identity.md). Значение непрозрачное и живёт
|
||||||
|
ниже 2^53 — тот же потолок, что у куки: выше JSON округляет при разборе.
|
||||||
|
"""
|
||||||
|
person_id = np.empty(cookies, dtype=np.uint64)
|
||||||
|
person_id[:people] = rng.integers(1, ids.LIMIT, people, dtype=np.uint64)
|
||||||
|
# Вторая кука пары повторяет ID своего человека: заказы с двух кук —
|
||||||
|
# это и есть склейка, ради которой пары заведены.
|
||||||
|
person_id[twins[:, 1]] = person_id[twins[:, 0]]
|
||||||
|
return person_id
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
│ ├── ось → номер дня: когорта этого дня
|
│ ├── ось → номер дня: когорта этого дня
|
||||||
│ └── предыстория → глубина: когорта дня до D0
|
│ └── предыстория → глубина: когорта дня до D0
|
||||||
└── дни
|
└── дни
|
||||||
└── номер дня → трафик, торговля, расхождения, опоздания
|
└── номер дня → трафик, торговля, заказная сторона, опоздания
|
||||||
|
|
||||||
Механизм — `numpy.random.SeedSequence`: потомок полностью определяется парой
|
Механизм — `numpy.random.SeedSequence`: потомок полностью определяется парой
|
||||||
(зерно, позиция в дереве), а не порядком вычислений. Сверено через Context7
|
(зерно, позиция в дереве), а не порядком вычислений. Сверено через Context7
|
||||||
@@ -35,7 +35,14 @@ class Component(IntEnum):
|
|||||||
|
|
||||||
TRAFFIC = 0
|
TRAFFIC = 0
|
||||||
COMMERCE = 1
|
COMMERCE = 1
|
||||||
DISCREPANCIES = 2
|
# Заказная сторона: деньги магазина и судьба заказа. Ветвится по дню
|
||||||
|
# рождения заказа — слепок несёт семь дней рождения сразу, и судьбу
|
||||||
|
# каждого заказа читает из его собственного дня
|
||||||
|
# (docs/architecture/orders/fate.md).
|
||||||
|
ORDERS = 2
|
||||||
|
# Событийную сторону — потерю и дубль события — заведёт этап 6; до тех
|
||||||
|
# пор позиция занята мёртвым именем прежней нарезки, и из него никто не
|
||||||
|
# бросает.
|
||||||
LATECOMERS = 3
|
LATECOMERS = 3
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ COUNTER_TIMEZONE_MINUTES = 240 # Europe/Samara
|
|||||||
# зависит — иначе опись перестала бы быть воспроизводимой.
|
# зависит — иначе опись перестала бы быть воспроизводимой.
|
||||||
ORIGIN = date(2026, 6, 1)
|
ORIGIN = date(2026, 6, 1)
|
||||||
|
|
||||||
|
# Окно изменяемости заказа, модельные дни: слепок бэкенда несёт заказы,
|
||||||
|
# рождённые в последние K дней, а за окном заказ неизменяем — выручка дня
|
||||||
|
# «дышит» K дней и потом замерзает (мастер-спека, раздел 2). Стоит рядом с
|
||||||
|
# D0 и поясом: это такое же свойство модельного времени, и собирает по нему
|
||||||
|
# окно команда слепка (#92).
|
||||||
|
ORDER_WINDOW_DAYS = 7
|
||||||
|
|
||||||
# Приток: сколько новых людей приходит в мир в средний день. Каждый приводит
|
# Приток: сколько новых людей приходит в мир в средний день. Каждый приводит
|
||||||
# свою куку, поэтому число это же — приток кук; вторые куки двухкуковых пар
|
# свою куку, поэтому число это же — приток кук; вторые куки двухкуковых пар
|
||||||
# добавляют к нему меньше процента.
|
# добавляют к нему меньше процента.
|
||||||
@@ -224,3 +231,17 @@ COUPONS = (
|
|||||||
# так и бывает (мастер-спека, раздел 1.2).
|
# так и бывает (мастер-спека, раздел 1.2).
|
||||||
GOAL_CART_ID = 42150001
|
GOAL_CART_ID = 42150001
|
||||||
GOAL_PURCHASE_ID = 42150002
|
GOAL_PURCHASE_ID = 42150002
|
||||||
|
|
||||||
|
# --- Числа заказов бэкенда: деньги магазина -------------------------------
|
||||||
|
|
||||||
|
# Стоимость доставки: пары «копейки — вес». Бесплатно (самовывоз или акция),
|
||||||
|
# обычная курьерская, срочная. Это единственные деньги заказа, которых нет
|
||||||
|
# ни в одном событии: выручка клиента — сумма позиций, скидку по промокоду
|
||||||
|
# считает бэкенд по таблице `COUPONS`, а доставку решает свой бросок
|
||||||
|
# заказной стороны. Числа черновые: калибровка — пересборка эталонного
|
||||||
|
# мира, этап 7.
|
||||||
|
DELIVERY_KOPECKS_WEIGHTS = (
|
||||||
|
(0, 35),
|
||||||
|
(29_900, 50),
|
||||||
|
(59_000, 15),
|
||||||
|
)
|
||||||
|
|||||||
@@ -52,19 +52,39 @@ def readable(column: NDArray[Any]) -> list[Any]:
|
|||||||
return column.tolist()
|
return column.tolist()
|
||||||
|
|
||||||
|
|
||||||
|
def orders(events: day.Day) -> list[list[Any]]:
|
||||||
|
"""Заказы дня в сравнимом виде: строка на заказ, все его поля."""
|
||||||
|
theirs = events.orders
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
theirs.order_id[number],
|
||||||
|
int(theirs.user_id[number]),
|
||||||
|
theirs.product[number].tolist(),
|
||||||
|
theirs.quantity[number].tolist(),
|
||||||
|
int(theirs.items_total[number]),
|
||||||
|
int(theirs.discount[number]),
|
||||||
|
int(theirs.delivery[number]),
|
||||||
|
int(theirs.total[number]),
|
||||||
|
]
|
||||||
|
for number in range(len(theirs))
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def snapshot(events: day.Day) -> dict[str, Any]:
|
def snapshot(events: day.Day) -> dict[str, Any]:
|
||||||
"""Слепок дня для сравнений: всё, что день отдал наружу.
|
"""Слепок дня для сравнений: всё, что день отдал наружу.
|
||||||
|
|
||||||
Порядок колонок в слепке живёт отдельным списком: словари сравниваются
|
Порядок колонок в слепке живёт отдельным списком: словари сравниваются
|
||||||
без оглядки на него, а порядок — часть обещания (он же порядок
|
без оглядки на него, а порядок — часть обещания (он же порядок
|
||||||
контракта схемы). Швы `page` и `product` — тоже часть отдаваемого, и
|
контракта схемы). Швы `page` и `product` — тоже часть отдаваемого, как
|
||||||
сторожить их надо тем же слепком, а не отдельной памятью.
|
и вторая половина дня, заказы: сторожить их надо тем же слепком, а не
|
||||||
|
отдельной памятью.
|
||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
"order": list(events.columns),
|
"order": list(events.columns),
|
||||||
"values": [readable(column) for column in events.columns.values()],
|
"values": [readable(column) for column in events.columns.values()],
|
||||||
"page": events.page.tolist(),
|
"page": events.page.tolist(),
|
||||||
"product": events.product.tolist(),
|
"product": events.product.tolist(),
|
||||||
|
"orders": orders(events),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -91,6 +111,7 @@ def test_the_snapshot_notices_everything_the_day_hands_out(weekday: day.Day):
|
|||||||
columns=dict(reversed(list(weekday.columns.items()))),
|
columns=dict(reversed(list(weekday.columns.items()))),
|
||||||
page=weekday.page,
|
page=weekday.page,
|
||||||
product=weekday.product,
|
product=weekday.product,
|
||||||
|
orders=weekday.orders,
|
||||||
)
|
)
|
||||||
assert snapshot(reordered) != original
|
assert snapshot(reordered) != original
|
||||||
|
|
||||||
@@ -101,6 +122,12 @@ def test_the_snapshot_notices_everything_the_day_hands_out(weekday: day.Day):
|
|||||||
shifted[0] += 1
|
shifted[0] += 1
|
||||||
assert snapshot(replace(weekday, page=shifted)) != original
|
assert snapshot(replace(weekday, page=shifted)) != original
|
||||||
|
|
||||||
|
paid = weekday.orders.total.copy()
|
||||||
|
paid[0] += 1
|
||||||
|
assert snapshot(replace(weekday, orders=replace(weekday.orders, total=paid))) != (
|
||||||
|
original
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_a_day_is_a_pure_function_of_the_seed_and_the_day():
|
def test_a_day_is_a_pure_function_of_the_seed_and_the_day():
|
||||||
first = snapshot(day.stream(CANONICAL_SEED, 3))
|
first = snapshot(day.stream(CANONICAL_SEED, 3))
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
"""Заказы бэкенда: проекция покупки, деньги магазина и мост к склейке.
|
||||||
|
|
||||||
|
Заказ здесь ещё без судьбы — статуса, моментов и дельты (тикет #91): всё,
|
||||||
|
что проверяется, это согласие двух источников по построению. Поэтому и
|
||||||
|
сверяется заказ не с внутренней структурой торговой половины (это было бы
|
||||||
|
сверкой кода с самим собой), а с событием, которое уехало в трекер.
|
||||||
|
|
||||||
|
Чистота заказов от зерна и дня сторожится там же, где чистота событий, —
|
||||||
|
слепком дня в `test_day.py`: заказы день отдаёт наружу наравне с потоком.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
from numpy.typing import NDArray
|
||||||
|
|
||||||
|
from clickstream_generator import catalog, commerce, day, orders, plan, schema, world
|
||||||
|
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 purchases_of(events: day.Day) -> dict[str, NDArray[Any]]:
|
||||||
|
"""Строки покупок дня — колонками, в порядке потока."""
|
||||||
|
here = events.columns["EventType"] == commerce.PURCHASE
|
||||||
|
return {name: value[here] for name, value in events.columns.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def user_id_by_order(events: day.Day) -> dict[str, int]:
|
||||||
|
return dict(
|
||||||
|
zip(events.orders.order_id, events.orders.user_id.tolist(), strict=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_order_of_the_day_is_a_purchase_of_the_day(weekday: day.Day):
|
||||||
|
"""Заказ — вторая проекция покупки, а не второе порождение.
|
||||||
|
|
||||||
|
У всякого заказа ровно одно событие `purchase`, и номер у них один:
|
||||||
|
сойтись двум источникам больше негде — сверка стенда соединяется
|
||||||
|
именно по нему.
|
||||||
|
"""
|
||||||
|
events = purchases_of(weekday)
|
||||||
|
numbers = [cell[0] for cell in events["purchaseID"]]
|
||||||
|
assert len(numbers) > 100
|
||||||
|
assert list(weekday.orders.order_id) == numbers
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_order_repeats_the_basket_and_the_money_of_its_purchase(weekday: day.Day):
|
||||||
|
"""Корзина и деньги клиента у заказа те же — он их не пересчитывает."""
|
||||||
|
goods = catalog.catalog()
|
||||||
|
events = purchases_of(weekday)
|
||||||
|
for number, order in enumerate(weekday.orders.order_id):
|
||||||
|
assert order == events["purchaseID"][number][0]
|
||||||
|
items = weekday.orders.product[number]
|
||||||
|
assert [goods.sku[item] for item in items.tolist()] == (
|
||||||
|
events["productID"][number].tolist()
|
||||||
|
)
|
||||||
|
assert weekday.orders.quantity[number].tolist() == (
|
||||||
|
events["productQuantity"][number].tolist()
|
||||||
|
)
|
||||||
|
# Выручка клиента и `items_total` бэкенда — одно число: в событии
|
||||||
|
# оно дробное, у заказа целое в копейках.
|
||||||
|
assert weekday.orders.items_total[number] == round(
|
||||||
|
events["purchaseRevenue"][number][0] * commerce.KOPECKS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_discount_comes_from_the_coupon_of_the_event(weekday: day.Day):
|
||||||
|
"""Промокод в событии обязан обернуться скидкой — иначе данные соврут."""
|
||||||
|
percent = dict(world.COUPONS)
|
||||||
|
events = purchases_of(weekday)
|
||||||
|
codes = [cell[0] for cell in events["purchaseCoupon"]]
|
||||||
|
assert sum(1 for code in codes if code) > 10
|
||||||
|
|
||||||
|
for number, code in enumerate(codes):
|
||||||
|
total = int(weekday.orders.items_total[number])
|
||||||
|
expected = total * percent[code] // 100 if code else 0
|
||||||
|
assert weekday.orders.discount[number] == expected
|
||||||
|
# Скидка без кода не берётся ниоткуда, а с кодом не съедает заказ.
|
||||||
|
assert np.all(weekday.orders.discount < weekday.orders.items_total)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_money_of_an_order_adds_up(weekday: day.Day):
|
||||||
|
"""`total` = `items_total` − `discount` + `delivery`, целыми копейками."""
|
||||||
|
money = weekday.orders
|
||||||
|
assert np.array_equal(
|
||||||
|
money.total, money.items_total - money.discount + money.delivery
|
||||||
|
)
|
||||||
|
for column in (money.items_total, money.discount, money.delivery, money.total):
|
||||||
|
assert np.issubdtype(column.dtype, np.integer)
|
||||||
|
assert np.all(column >= 0)
|
||||||
|
|
||||||
|
prices = {price for price, _ in world.DELIVERY_KOPECKS_WEIGHTS}
|
||||||
|
assert set(money.delivery.tolist()) == prices
|
||||||
|
# Доставка — деньги, которых нет ни в одном событии: без неё «считаем по
|
||||||
|
# бэкенду» ничего не значило бы.
|
||||||
|
assert np.any(money.total != money.items_total - money.discount)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_order_side_draws_from_its_own_named_branch(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
):
|
||||||
|
"""Заказная сторона берёт свой подпоток и ветвится по дню рождения заказа.
|
||||||
|
|
||||||
|
Адрес в дереве зерна и есть контракт компонента (спека генератора,
|
||||||
|
раздел 2): правка заказной механики не двигает ни трафик, ни торговлю,
|
||||||
|
а слепок читает судьбу заказа из дня, в который заказ родился.
|
||||||
|
"""
|
||||||
|
asked: list[tuple[int, int, Component]] = []
|
||||||
|
honest = orders.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(orders, "day_stream", spy)
|
||||||
|
day.stream(CANONICAL_SEED, WEEKDAY)
|
||||||
|
assert asked == [(CANONICAL_SEED, WEEKDAY, Component.ORDERS)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_user_id_is_the_person_behind_the_cookie(weekday: day.Day):
|
||||||
|
"""Личность заказу даёт план, а не разбор трекера."""
|
||||||
|
audience = plan.audience(CANONICAL_SEED, WEEKDAY)
|
||||||
|
person = dict(
|
||||||
|
zip(audience.client_id.tolist(), audience.person_id.tolist(), strict=True)
|
||||||
|
)
|
||||||
|
events = purchases_of(weekday)
|
||||||
|
for number, cookie in enumerate(events["ClientID"].tolist()):
|
||||||
|
assert weekday.orders.user_id[number] == person[cookie]
|
||||||
|
assert np.all(weekday.orders.user_id > 0)
|
||||||
|
assert weekday.orders.user_id.max() < 2**53
|
||||||
|
|
||||||
|
|
||||||
|
def test_both_cookies_of_a_pair_order_as_one_user(week: list[day.Day]):
|
||||||
|
"""Мост к склейке: заказы с двух кук пары несут один `user_id`.
|
||||||
|
|
||||||
|
Проверяются пары, у которых оба назначенных дня попали внутрь недели:
|
||||||
|
остальные ждут своего дня за горизонтом.
|
||||||
|
"""
|
||||||
|
users: list[dict[int, set[int]]] = []
|
||||||
|
for events in week:
|
||||||
|
by_order = user_id_by_order(events)
|
||||||
|
rows = purchases_of(events)
|
||||||
|
seen: dict[int, set[int]] = {}
|
||||||
|
for cookie, number in zip(
|
||||||
|
rows["ClientID"].tolist(),
|
||||||
|
(cell[0] for cell in rows["purchaseID"]),
|
||||||
|
strict=True,
|
||||||
|
):
|
||||||
|
seen.setdefault(cookie, set()).add(by_order[number])
|
||||||
|
users.append(seen)
|
||||||
|
|
||||||
|
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
|
||||||
|
first, second = (
|
||||||
|
users[day_number][int(cohort.client_id[cookie])]
|
||||||
|
for cookie, day_number in zip(cookies, days, strict=True)
|
||||||
|
)
|
||||||
|
assert len(first | second) == 1
|
||||||
|
assert pairs > 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_user_id_never_reaches_the_metrica_event(weekday: day.Day):
|
||||||
|
"""Кликстрим анонимен: личность держится формой контракта, а не забывчивостью.
|
||||||
|
|
||||||
|
Спрашивается двумя способами, потому что утечь личность может двумя.
|
||||||
|
Числом — тогда её видно в числовых колонках дня, и они сверяются со
|
||||||
|
всеми личностями дневной аудитории. Текстом — тогда сравнение чисел
|
||||||
|
её прозевало бы, поэтому строки покупок читаются целиком, вместе с
|
||||||
|
сырым `ecommerce`: этот блок собирается руками, и дописать в него
|
||||||
|
лишнее поле проще всего. Имени для личности в контракте схемы нет.
|
||||||
|
"""
|
||||||
|
people = set(plan.audience(CANONICAL_SEED, WEEKDAY).person_id.tolist())
|
||||||
|
assert people
|
||||||
|
names = {column.name.lower() for column in schema.COLUMNS}
|
||||||
|
assert not (names & {"userid", "user_id", "personid", "person_id"})
|
||||||
|
for name, value in weekday.columns.items():
|
||||||
|
if np.issubdtype(value.dtype, np.integer):
|
||||||
|
assert not (set(value.tolist()) & people), name
|
||||||
|
|
||||||
|
events = purchases_of(weekday)
|
||||||
|
text = "\n".join(
|
||||||
|
" ".join(str(value[row]) for value in events.values())
|
||||||
|
for row in range(events["WatchID"].size)
|
||||||
|
)
|
||||||
|
for person in weekday.orders.user_id.tolist():
|
||||||
|
assert str(person) not in text
|
||||||
|
assert set(weekday.orders.user_id.tolist()) <= people
|
||||||
@@ -43,6 +43,7 @@ def same_cohort(left: plan.Cohort, right: plan.Cohort) -> bool:
|
|||||||
and np.array_equal(left.pair_order_days, right.pair_order_days)
|
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.device, right.device)
|
||||||
and np.array_equal(left.city, right.city)
|
and np.array_equal(left.city, right.city)
|
||||||
|
and np.array_equal(left.person_id, right.person_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -175,6 +176,33 @@ def test_the_passport_of_a_pair_is_one_person_with_two_devices():
|
|||||||
assert np.all(phone[cohort.device[first]] != phone[cohort.device[second]])
|
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():
|
def test_the_passport_points_into_the_directories():
|
||||||
cohort = plan.cohort(CANONICAL_SEED, 0)
|
cohort = plan.cohort(CANONICAL_SEED, 0)
|
||||||
for passport, table in (
|
for passport, table in (
|
||||||
@@ -278,5 +306,6 @@ def test_plan_arrays_are_whole_numbers():
|
|||||||
cohort.active_day,
|
cohort.active_day,
|
||||||
cohort.pair_cookies,
|
cohort.pair_cookies,
|
||||||
cohort.pair_order_days,
|
cohort.pair_order_days,
|
||||||
|
cohort.person_id,
|
||||||
):
|
):
|
||||||
assert np.issubdtype(array.dtype, np.integer)
|
assert np.issubdtype(array.dtype, np.integer)
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ def test_day_components_do_not_share_randomness():
|
|||||||
components = (
|
components = (
|
||||||
Component.TRAFFIC,
|
Component.TRAFFIC,
|
||||||
Component.COMMERCE,
|
Component.COMMERCE,
|
||||||
Component.DISCREPANCIES,
|
Component.ORDERS,
|
||||||
Component.LATECOMERS,
|
Component.LATECOMERS,
|
||||||
)
|
)
|
||||||
assert len({int(component) for component in components}) == 4
|
assert len({int(component) for component in components}) == 4
|
||||||
|
|||||||
Reference in New Issue
Block a user