From b2fcc7e593a3242058618bd8b99b87aad2c6ec2a Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Tue, 18 Aug 2026 12:30:13 +0300 Subject: [PATCH] =?UTF-8?q?feat(generator):=20=D0=B7=D0=B0=D0=BA=D0=B0?= =?UTF-8?q?=D0=B7=D1=8B=20=D0=B4=D0=BD=D1=8F=20=E2=80=94=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B5=D0=BA=D1=86=D0=B8=D1=8F=20=D0=BF=D0=BE=D0=BA=D1=83?= =?UTF-8?q?=D0=BF=D0=BE=D0=BA=20=D1=81=20=D0=B4=D0=B5=D0=BD=D1=8C=D0=B3?= =?UTF-8?q?=D0=B0=D0=BC=D0=B8=20=D0=BC=D0=B0=D0=B3=D0=B0=D0=B7=D0=B8=D0=BD?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - тикет #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 --- docs/architecture/orders/README.md | 6 +- docs/architecture/orders/fate.md | 11 +- docs/architecture/orders/snapshot.md | 8 + generator/README.md | 18 +- .../src/clickstream_generator/commerce.py | 170 +++++++++++++-- generator/src/clickstream_generator/day.py | 49 +++-- generator/src/clickstream_generator/orders.py | 109 ++++++++++ generator/src/clickstream_generator/plan.py | 34 ++- generator/src/clickstream_generator/seeds.py | 11 +- generator/src/clickstream_generator/world.py | 21 ++ generator/tests/test_day.py | 31 ++- generator/tests/test_orders.py | 203 ++++++++++++++++++ generator/tests/test_plan.py | 29 +++ generator/tests/test_seeds.py | 2 +- 14 files changed, 650 insertions(+), 52 deletions(-) create mode 100644 generator/src/clickstream_generator/orders.py create mode 100644 generator/tests/test_orders.py diff --git a/docs/architecture/orders/README.md b/docs/architecture/orders/README.md index 7f3f50f..a35849d 100644 --- a/docs/architecture/orders/README.md +++ b/docs/architecture/orders/README.md @@ -49,8 +49,10 @@ ## Открытые решения -- **Имена подпотоков сторон судьбы** — при реализации; из мёртвых имён никто - не бросает, переименование ничего не сдвигает (#72). +- **Имя подпотока событийной стороны** — вместе с её содержимым на этапе 6; + из мёртвых имён никто не бросает, переименование ничего не сдвигает (#72). + Заказная сторона уже названа — подпоток `ORDERS` ([судьба + заказа](fate.md)). - **Конкретные веса и доли** — таблицы исходов, моментов, задержки опоздания, доли классов, стоимость доставки — калибровка при реализации; финальная фиксация чисел — пересборка эталонного мира, этап 7. При пересборке правки diff --git a/docs/architecture/orders/fate.md b/docs/architecture/orders/fate.md index c5534b5..2f27ad8 100644 --- a/docs/architecture/orders/fate.md +++ b/docs/architecture/orders/fate.md @@ -14,10 +14,13 @@ не двигает хеши событий, правка событийной не двигает байты слепка, и по покрасневшим хешам видно, какую сторону трогали. Прежние имена `DISCREPANCIES` и `LATECOMERS` решения не переживают — они названы по классам витрины, а -компонент называет часть мира; новые стороны занимают те же позиции, имена — -при реализации. Отклонено: *один компонент на всю судьбу* — правка событийной -механики молча меняла бы байты слепка; *компонент на класс* — пять имён под -ручки калибровки, которые крутятся разом. +компонент называет часть мира; новые стороны занимают те же позиции. +Заказная сторона названа при реализации — подпоток `ORDERS` на позиции 2; +событийная получит имя вместе с содержимым на этапе 6, до тех пор позиция 3 +занята мёртвым именем прежней нарезки. Отклонено: *один компонент на всю +судьбу* — правка событийной механики молча меняла бы байты слепка; +*компонент на класс* — пять имён под ручки калибровки, которые крутятся +разом. **Правило формы, без которого разделение не работает: броски заказной стороны делаются на полную длину дня, а не на отобранных заказах.** Иначе длина броска diff --git a/docs/architecture/orders/snapshot.md b/docs/architecture/orders/snapshot.md index d733745..9d27a53 100644 --- a/docs/architecture/orders/snapshot.md +++ b/docs/architecture/orders/snapshot.md @@ -28,6 +28,14 @@ В модели строка заказа в базе источника создаётся синхронно с покупкой, поэтому день рождения заказа и день создания строки совпадают. +**Деньги заказа** складываются целыми копейками: `items_total` — сумма +позиций, посчитанная торговой половиной, то же число, что уехало клиентским +`purchaseRevenue`; `discount` — процент промокода от неё, округлённый вниз; +`delivery` — бросок заказной стороны по таблице целых весов, единственные +деньги заказа, которых нет ни в одном событии; `total` = `items_total` − +`discount` + `delivery`. Отсюда и правило витрин «деньги считаем по +бэкенду»: про скидку и доставку клиент не знает вовсе. + Отклонено: *выводить заказ разбором собственного вывода* (`purchaseID`, сырой `ecommerce`) — бэкенд стал бы читателем трекера ровно там, где стенд учит, что это разные источники; *независимая модель бэкенда* (заказ первичен, событие — diff --git a/generator/README.md b/generator/README.md index 6737fd5..3f4e082 100644 --- a/generator/README.md +++ b/generator/README.md @@ -7,8 +7,9 @@ События уже есть и уже уезжают: день-функция отдаёт по паре (зерно, D) упорядоченный поток трёх видов — просмотр страницы, корзина, покупка, — канонический сериализатор превращает его в JSON, а проигрыватель гонит в файл -или в Kafka. Клиентская сторона на этом целая; заказы бэкенда — за следующими -этапами. +или в Kafka. Клиентская сторона на этом целая. Второй источник строится: +день отдаёт и заказы бэкенда — проекцию своих покупок с деньгами магазина, — +а судьба заказа и отправка слепка идут следующими тикетами. ## Как это работает @@ -30,7 +31,8 @@ D0 живёт предыстория, поэтому любой день соб значит сдвинуть все броски после него, а с ними и весь мир: события того же дня станут другими, счётчики канонического мира разойдутся с описью, и поймается это не ошибкой, а красным чеком. Ровно поэтому паспорта кук в -`plan.cohort` бросаются последними. +`plan.cohort` брошены последними, а личность человека приписана уже за ними: +куки, пары и паспорта остались теми же до байта. Случайность целочисленная — только диапазоны и выбор по целым весам: готовые распределения numpy расходятся между версиями и архитектурами, а обещано @@ -44,8 +46,8 @@ D0 живёт предыстория, поэтому любой день соб - `src/clickstream_generator/seeds.py` — иерархия зёрен: кто из какого подпотока берёт случайность. На ней держится весь детерминизм. - `src/clickstream_generator/plan.py` — план состава: кто есть в мире в - день D. Когорты, приток, двухкуковые пары, паспорта кук и счётчики — до - генерации событий. + день D. Когорты, приток, двухкуковые пары, паспорта кук, человек за кукой + и счётчики — до генерации событий. - `src/clickstream_generator/weights.py` — выбор по целым весам: один приём на весь генератор, чтобы дисциплина целочисленной случайности не жила копиями. @@ -56,10 +58,14 @@ D0 живёт предыстория, поэтому любой день соб `data/catalog/products.csv`, общего у генератора и словаря ClickHouse. - `src/clickstream_generator/day.py` — день-функция: визиты, страницы, атрибуция, устройство и гео. Там же правила резки визитов и шов, на - который садятся торговые события. + который садятся торговые события; прожитый день отдаёт обе половины — + поток событий и заказы. - `src/clickstream_generator/commerce.py` — торговые события: что легло в корзину, что из этого куплено, деньги, номера заказов и сырой `ecommerce`. Своя случайность, поэтому правка торговли трафик не двигает. +- `src/clickstream_generator/orders.py` — заказы бэкенда: вторая проекция + покупки. Личность покупателя и деньги магазина — скидка по промокоду, + доставка, итог; своя случайность, ветвящаяся по дню рождения заказа. - `src/clickstream_generator/ids.py` — номера событий: неповторяющиеся и ниже 2^53. Обещание одно на обе половины дня, поэтому и живёт отдельно. - `src/clickstream_generator/serialize.py` — канонический сериализатор: diff --git a/generator/src/clickstream_generator/commerce.py b/generator/src/clickstream_generator/commerce.py index e6841dc..751bab5 100644 --- a/generator/src/clickstream_generator/commerce.py +++ b/generator/src/clickstream_generator/commerce.py @@ -39,9 +39,17 @@ дошедшие до потока дня, в порядке событий — до всяких потерь; поэтому номер присваивается последним ходом, когда поток уже упорядочен. +**Второй выход — покупки дня.** Кроме потока событий торговая половина +отдаёт покупки готовой структурой: номер заказа, корзина, деньги клиента, +промокод и человек за кукой. Заказная сторона берёт их такими и ничего не +пересчитывает: заказ — вторая проекция той же покупки, поэтому согласие двух +источников не удерживается, а выходит по построению +(docs/architecture/orders/snapshot.md). + **Случайность — подпоток `COMMERCE`** (спека генератора, раздел 2): правка торгового поведения не сдвигает трафиковый поток. Броски целые и векторные; -посточно собираются только строки — их numpy не умеет. +посточно собираются только строки — их numpy не умеет. Новых бросков заказы +сюда не добавляют: свои решения заказная сторона тянет из своего подпотока. """ from dataclasses import dataclass @@ -123,19 +131,61 @@ class _Draws: 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) class _Events: """Строки одного вида торговых событий, готовые встать в поток. `raw` — сырой `ecommerce` каждой строки ещё объектом: номер заказа в нём появится, когда поток будет упорядочен, а строка станет байтами один - раз, каноническим сериализатором. + раз, каноническим сериализатором. `basket` говорит, чья корзина стоит за + строкой: по нему покупка находит свою после того, как поток упорядочен. """ columns: dict[str, NDArray[Any]] page: NDArray[np.uint8] product: NDArray[np.int64] 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( @@ -144,24 +194,34 @@ def weave( columns: dict[str, NDArray[Any]], page: NDArray[np.uint8], 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) baskets = _baskets(rng, page, product, columns["VisitID"]) if not len(baskets): - return columns, page, product + return columns, page, product, _NO_PURCHASES goods = catalog.catalog() draws = _draws(rng, baskets) + revenue = _revenue(goods, baskets, draws) + coupons = _coupon_codes(draws) events = ( _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: @@ -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) +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( columns: dict[str, NDArray[Any]], baskets: _Baskets, @@ -329,6 +419,7 @@ def _cart_events( page=np.full(positions, Page.PRODUCT, dtype=np.uint8), product=baskets.product.copy(), raw=[{"currencyCode": world.CURRENCY, ADD_ACTION: block} for block in blocks], + basket=baskets.basket, ) @@ -337,6 +428,8 @@ def _order_events( baskets: _Baskets, goods: catalog.Catalog, draws: _Draws, + revenue: NDArray[np.int64], + coupons: NDArray[np.object_], ) -> _Events: """Строки `purchase`: по одной на корзину, дошедшую до подтверждения.""" ordered = np.flatnonzero(baskets.confirmation >= 0) @@ -352,14 +445,9 @@ def _order_events( rows.update(side) # Выручка клиента — сумма позиций без скидки и доставки, целыми копейками. - kopecks = [ - int((goods.price[baskets.product[group]] * draws.quantity[group]).sum()) - for group in bought - ] - codes = [ - world.COUPONS[number][0] if number >= 0 else "" - for number in draws.coupon[ordered].tolist() - ] + # Считана она один раз на день: тем же числом её возьмёт заказ бэкенда. + kopecks = revenue[ordered].tolist() + codes = coupons[ordered].tolist() rows["purchaseRevenue"] = _cells( [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), product=np.full(ordered.size, -1, dtype=np.int64), raw=raw, + basket=ordered, ) @@ -479,8 +568,15 @@ def _stream( page: NDArray[np.uint8], product: NDArray[np.int64], 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 trade = { 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[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"])) rows = {name: value[order] for name, value in rows.items()} _seal(rows, raw, place[order], _order_prefix(columns)) - return rows, page[order], product[order] + return rows, page[order], product[order], basket[order] def _seal( @@ -537,6 +640,35 @@ def _seal( 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: """Начало модельных суток абсолютной меткой: полночь в поясе счётчика.""" date: np.datetime64 = columns["EventDate"][0] diff --git a/generator/src/clickstream_generator/day.py b/generator/src/clickstream_generator/day.py index 59df10f..0a186cf 100644 --- a/generator/src/clickstream_generator/day.py +++ b/generator/src/clickstream_generator/day.py @@ -36,15 +36,21 @@ гарантия, ради неё мы сужаем свободу старта. Цена названа — около 24 визитов в день из ~9,5 тыс. не начинаются в последние минуты суток. -**Шов с торговыми событиями.** Поток несёт, кроме колонок, два выровненных -по строкам ряда: `page` — какая это страница магазина, и `product` — какой -товар показывала карточка (−1 у прочих страниц). По ним `commerce` знает и -то, куда сажать событие (карточка, подтверждение), и то, что посетитель на -самом деле смотрел: товар в корзине, которого никто не открывал, — видимая -глупость в воронке. Визит с назначенным заказом всегда доходит до -`/confirmation`, а перед корзиной у него всегда есть карточка товара. -Случайность у половин разная: трафик берёт подпоток `TRAFFIC`, торговля — -`COMMERCE`, и правка одной не сдвигает другую. +**Шов с торговыми событиями.** Поток несёт, кроме колонок, три выровненных +по строкам ряда: `page` — какая это страница магазина, `product` — какой +товар показывала карточка (−1 у прочих страниц), и `person` — человек за +кукой этой строки. По первым двум `commerce` знает и то, куда сажать событие +(карточка, подтверждение), и то, что посетитель на самом деле смотрел: товар +в корзине, которого никто не открывал, — видимая глупость в воронке. Третий +в события не попадает вовсе: личность нужна заказу, а кликстрим анонимен. +Визит с назначенным заказом всегда доходит до `/confirmation`, а перед +корзиной у него всегда есть карточка товара. Случайность у половин разная: +трафик берёт подпоток `TRAFFIC`, торговля — `COMMERCE`, заказы — свой, и +правка одной не сдвигает другие. + +**Заказы бэкенда** день отдаёт второй половиной: покупки, которые собрала +торговая половина, заказная превращает в заказы дня — с личностью +покупателя и деньгами магазина (`orders`). """ from dataclasses import dataclass @@ -53,7 +59,7 @@ from typing import Any import numpy as np 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.seeds import Component, day_stream 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) class Day: - """Поток событий одного дня: колонки выгрузки и страницы за ними. + """Прожитый день: поток событий трекера и заказы бэкенда. Строки упорядочены по времени — так их и проиграет проигрыватель. `columns` — колонки контракта схемы по его порядку, все до одной; `page` и `product` выровнены по тем же строкам (см. шов в докстринге - модуля). + модуля). `orders` — вторая половина дня: заказы, рождённые в нём. + + Половины уезжают разными командами и в разные топики, но считаются + вместе: заказ — проекция покупки, и порознь им разойтись негде. """ day: int columns: dict[str, NDArray[Any]] page: NDArray[np.uint8] product: NDArray[np.int64] + orders: orders.Orders def __len__(self) -> int: return self.page.size @@ -149,15 +159,24 @@ def stream(seed: int, day: int) -> Day: 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, day, {name: value[order] for name, value in columns.items()}, page[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: diff --git a/generator/src/clickstream_generator/orders.py b/generator/src/clickstream_generator/orders.py new file mode 100644 index 0000000..2cc6fd1 --- /dev/null +++ b/generator/src/clickstream_generator/orders.py @@ -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 diff --git a/generator/src/clickstream_generator/plan.py b/generator/src/clickstream_generator/plan.py index 5b77d1b..29fc383 100644 --- a/generator/src/clickstream_generator/plan.py +++ b/generator/src/clickstream_generator/plan.py @@ -16,6 +16,9 @@ каждая из двух кук обязана оформить заказ; - паспорт куки — устройство и город: они у куки одни и те же во всех её днях, а знает об этом только план (у пары один город на двоих); +- личность — `person_id` человека за кукой: у двух кук пары он один, и + заказ бэкенда показывает его как `user_id` + (docs/architecture/orders/identity.md); - счётчики — приток по дням, дневная и накопленная аудитория, пары. Единица дня здесь — день активности куки, а не визит: слово «визит» @@ -92,6 +95,8 @@ class Cohort: # Паспорт куки: номер профиля устройства и номер города в справочниках. device: NDArray[np.int64] city: NDArray[np.int64] + # Человек за кукой: непрозрачный ID, одинаковый у двух кук пары. + person_id: NDArray[np.uint64] def __post_init__(self) -> None: """Когорта запоминается, поэтому массивы отдаются только на чтение. @@ -132,6 +137,7 @@ class Cohort: assigned_order=np.isin(here, ordering), device=self.device[here], city=self.city[here], + person_id=self.person_id[here], ) @@ -147,6 +153,8 @@ class DayAudience: # Паспорт куки: номера строк в справочниках устройств и городов. device: NDArray[np.int64] city: NDArray[np.int64] + # Человек за кукой: его заказ покажет этот ID как `user_id`. + person_id: NDArray[np.uint64] @dataclass(frozen=True, slots=True) @@ -200,8 +208,11 @@ def cohort(seed: int, day: int) -> Cohort: rng, twins, active_cookie, active_day, cookies ) # Паспорт бросается последним — после всего, что уже измерено: тогда - # счётчики канонического мира от этой добавки не двигаются. + # счётчики канонического мира от этой добавки не двигаются. По тому же + # правилу за ним приписана личность: этап 3 дописал её в конец, и куки, + # пары и паспорта остались теми же до байта. device, city = _passports(rng, twins, cookies) + person_id = _persons(rng, twins, people, cookies) return Cohort( day=day, people=people, @@ -214,6 +225,7 @@ def cohort(seed: int, day: int) -> Cohort: pair_order_days=pair_order_days, device=device, 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]), device=np.concatenate([part.device 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) ] 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 diff --git a/generator/src/clickstream_generator/seeds.py b/generator/src/clickstream_generator/seeds.py index 31439d3..b0375e3 100644 --- a/generator/src/clickstream_generator/seeds.py +++ b/generator/src/clickstream_generator/seeds.py @@ -7,7 +7,7 @@ │ ├── ось → номер дня: когорта этого дня │ └── предыстория → глубина: когорта дня до D0 └── дни - └── номер дня → трафик, торговля, расхождения, опоздания + └── номер дня → трафик, торговля, заказная сторона, опоздания Механизм — `numpy.random.SeedSequence`: потомок полностью определяется парой (зерно, позиция в дереве), а не порядком вычислений. Сверено через Context7 @@ -35,7 +35,14 @@ class Component(IntEnum): TRAFFIC = 0 COMMERCE = 1 - DISCREPANCIES = 2 + # Заказная сторона: деньги магазина и судьба заказа. Ветвится по дню + # рождения заказа — слепок несёт семь дней рождения сразу, и судьбу + # каждого заказа читает из его собственного дня + # (docs/architecture/orders/fate.md). + ORDERS = 2 + # Событийную сторону — потерю и дубль события — заведёт этап 6; до тех + # пор позиция занята мёртвым именем прежней нарезки, и из него никто не + # бросает. LATECOMERS = 3 diff --git a/generator/src/clickstream_generator/world.py b/generator/src/clickstream_generator/world.py index e25e6e8..03b0579 100644 --- a/generator/src/clickstream_generator/world.py +++ b/generator/src/clickstream_generator/world.py @@ -31,6 +31,13 @@ COUNTER_TIMEZONE_MINUTES = 240 # Europe/Samara # зависит — иначе опись перестала бы быть воспроизводимой. ORIGIN = date(2026, 6, 1) +# Окно изменяемости заказа, модельные дни: слепок бэкенда несёт заказы, +# рождённые в последние K дней, а за окном заказ неизменяем — выручка дня +# «дышит» K дней и потом замерзает (мастер-спека, раздел 2). Стоит рядом с +# D0 и поясом: это такое же свойство модельного времени, и собирает по нему +# окно команда слепка (#92). +ORDER_WINDOW_DAYS = 7 + # Приток: сколько новых людей приходит в мир в средний день. Каждый приводит # свою куку, поэтому число это же — приток кук; вторые куки двухкуковых пар # добавляют к нему меньше процента. @@ -224,3 +231,17 @@ COUPONS = ( # так и бывает (мастер-спека, раздел 1.2). GOAL_CART_ID = 42150001 GOAL_PURCHASE_ID = 42150002 + +# --- Числа заказов бэкенда: деньги магазина ------------------------------- + +# Стоимость доставки: пары «копейки — вес». Бесплатно (самовывоз или акция), +# обычная курьерская, срочная. Это единственные деньги заказа, которых нет +# ни в одном событии: выручка клиента — сумма позиций, скидку по промокоду +# считает бэкенд по таблице `COUPONS`, а доставку решает свой бросок +# заказной стороны. Числа черновые: калибровка — пересборка эталонного +# мира, этап 7. +DELIVERY_KOPECKS_WEIGHTS = ( + (0, 35), + (29_900, 50), + (59_000, 15), +) diff --git a/generator/tests/test_day.py b/generator/tests/test_day.py index b91ef5c..bce6ffa 100644 --- a/generator/tests/test_day.py +++ b/generator/tests/test_day.py @@ -52,19 +52,39 @@ def readable(column: NDArray[Any]) -> list[Any]: 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]: """Слепок дня для сравнений: всё, что день отдал наружу. Порядок колонок в слепке живёт отдельным списком: словари сравниваются без оглядки на него, а порядок — часть обещания (он же порядок - контракта схемы). Швы `page` и `product` — тоже часть отдаваемого, и - сторожить их надо тем же слепком, а не отдельной памятью. + контракта схемы). Швы `page` и `product` — тоже часть отдаваемого, как + и вторая половина дня, заказы: сторожить их надо тем же слепком, а не + отдельной памятью. """ return { "order": list(events.columns), "values": [readable(column) for column in events.columns.values()], "page": events.page.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()))), page=weekday.page, product=weekday.product, + orders=weekday.orders, ) assert snapshot(reordered) != original @@ -101,6 +122,12 @@ def test_the_snapshot_notices_everything_the_day_hands_out(weekday: day.Day): shifted[0] += 1 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(): first = snapshot(day.stream(CANONICAL_SEED, 3)) diff --git a/generator/tests/test_orders.py b/generator/tests/test_orders.py new file mode 100644 index 0000000..183fc80 --- /dev/null +++ b/generator/tests/test_orders.py @@ -0,0 +1,203 @@ +"""Заказы бэкенда: проекция покупки, деньги магазина и мост к склейке. + +Заказ здесь ещё без судьбы — статуса, моментов и дельты (тикет #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): + """Кликстрим анонимен: личность держится формой контракта, а не забывчивостью. + + Проверяются числовые колонки события целиком: личность — число того же + порядка, что кука и номер события, и попасть она могла бы только в + такую. Имени для неё в контракте нет вовсе. + """ + 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 + assert set(weekday.orders.user_id.tolist()) <= people diff --git a/generator/tests/test_plan.py b/generator/tests/test_plan.py index 6ef3b29..7667ee0 100644 --- a/generator/tests/test_plan.py +++ b/generator/tests/test_plan.py @@ -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.device, right.device) 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]]) +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 ( @@ -278,5 +306,6 @@ def test_plan_arrays_are_whole_numbers(): cohort.active_day, cohort.pair_cookies, cohort.pair_order_days, + cohort.person_id, ): assert np.issubdtype(array.dtype, np.integer) diff --git a/generator/tests/test_seeds.py b/generator/tests/test_seeds.py index 2cd8be6..4a87a65 100644 --- a/generator/tests/test_seeds.py +++ b/generator/tests/test_seeds.py @@ -77,7 +77,7 @@ def test_day_components_do_not_share_randomness(): components = ( Component.TRAFFIC, Component.COMMERCE, - Component.DISCREPANCIES, + Component.ORDERS, Component.LATECOMERS, ) assert len({int(component) for component in components}) == 4