From a7442765fff1da5ffceffd9de75cf16910c37772 Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Sun, 2 Aug 2026 17:17:31 +0300 Subject: [PATCH 1/8] =?UTF-8?q?docs(generator):=20=D1=80=D0=B5=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20#40=20=E2=80=94=20=D1=82=D0=BE=D1=80=D0=B3?= =?UTF-8?q?=D0=BE=D0=B2=D1=8B=D0=B5=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8?= =?UTF-8?q?=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - перед реализацией торговых событий грилинг закрыл шесть развилок; без записи решения и отклонённые варианты потерялись бы, а часть из них выходит за границы тикета и меняет постановку. - Что: - в раздел 9 спеки генератора добавлен блок «Решено при исполнении #40»: корзина шире заказа, метка покупателя становится значимой, место торговых событий, номер заказа, промокод, единицы денег, разная длина массивов purchase* и product*, orjson, хвост покупки у границы суток. - в раздел 8 добавлены хвосты этапам 3 и 4: скидка по промокоду и намеренное расхождение сумм. - в словарь добавлены «покупатель» и «торговое событие». - Проверка: - чтением: docs/specs/2026-08-01-generator.md, разделы 8 и 9; CONTEXT.md. --- CONTEXT.md | 12 +++++ docs/specs/2026-08-01-generator.md | 74 ++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/CONTEXT.md b/CONTEXT.md index 593d27f..61637b2 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -63,11 +63,23 @@ _Избегать_: визит (визит — про сессию) устройстве. Держит их план состава, разворачивает в поля события день-функция; у двухкуковой пары город один на две куки, устройства разные. +**Покупатель**: +Человек, которого план состава пометил склонным покупать. Метка значима: +помеченный доходит до заказа заметно чаще прочих, но и непомеченный иногда +покупает. Из покупателей отбираются двухкуковые пары. +_Избегать_: «покупатель» про того, кто купил в конкретный день — это визит +с заказом. + **День-функция**: Функция (зерно, номер дня), выдающая упорядоченный поток событий этих модельных суток. Состояния между днями нет: день D не зависит от того, прожиты ли дни до него. +**Торговое событие**: +Событие корзины или покупки — отдельная строка потока, а не просмотр +страницы. Садится на ту страницу, где случилось: корзина — на карточку +товара, покупка — на страницу подтверждения заказа. + **Каталог товаров**: `data/catalog/products.csv` — общий справочник генератора и словаря ClickHouse. Форма файла решена, длина — нет: строки дописываются. diff --git a/docs/specs/2026-08-01-generator.md b/docs/specs/2026-08-01-generator.md index 5617957..138bada 100644 --- a/docs/specs/2026-08-01-generator.md +++ b/docs/specs/2026-08-01-generator.md @@ -390,6 +390,13 @@ pytest-тест с маркером `perf` и таймаутом-обрубан ## 8. Хвосты следующим этапам +- **Этап 3 (заказы бэкенда)**: заказу, у которого в клиентском событии стоит + промокод, бэкенд обязан дать скидку — иначе данные соврут. Номер заказа обе + стороны берут один и тот же (раздел 9). +- **Этап 4 (сверка)**: расхождение сумм само не появится — цены каталога + кратны рублю, и `Float64` на таких числах не плывёт. Класс `amount_delta` + придётся создавать намеренно, вероятностью в генераторе, — мастер-спека + (раздел 4) это и допускает. - **Этап 5 (Airflow)**: живой день со стороны хранилища — обычный ETL-даг по расписанию (~раз в 24 минуты); генератор не дорабатывается. - **Этап 7 (эталонный мир)**: пересборка — это манифест, не артефакт; @@ -542,6 +549,73 @@ pytest-тест с маркером `perf` и таймаутом-обрубан `/confirmation`, а перед корзиной у него всегда есть карточка. Своей случайности #39 у #40 не занимает: подпоток `COMMERCE` не тронут. +Решено при исполнении #40 (2026-08-02) — решения владельца до реализации: + +- **Корзина шире заказа.** Посетитель кладёт в корзину товары тех карточек, + которые открывал в этом визите, а покупает не всё: около 15% позиций + остаются брошенными. Иначе событие корзины не рассказывало бы ничего сверх + покупки — заказ был бы её точной копией, и сравнивать было бы нечего. + Замерено на каноническом мире до правки воронки (ниже): у покупающего + визита 2,1 карточки, в заказе + выходит 1,9 позиции, однопозиционных заказов около половины. Отклонено: + *заказ всегда из одного товара* — список позиций как урок умирает, а на нём + стоит единственный носитель навыка «вложенный JSON» (мастер-спека, + раздел 2); *досыпать в корзину товары, которых посетитель не открывал* — + ломает шов #39 ровно там, ради чего он строился; *бросать много* (40% и + выше) — две трети заказов схлопываются в один товар. +- **Покупатель перестаёт быть словом.** Метка плана — «этот человек склонен + покупать», 5% людей когорты — до сих пор ни на что не влияла: кто купит, + решал одинаковый для всех бросок в дне. Свойство человека в данных не + читалось, постоянных покупателей в мире не было. Теперь метка правит + воронкой: помеченный доходит до заказа заметно чаще прочих, но и + непомеченный иногда покупает. Общее число покупок и общая доля визитов с + корзиной остаются прежними — меняется лишь то, кто эти визиты совершает; + точные доли — калибровка при реализации, требование сторожит тест. + Уточнение к разделу 1: вольные покупки по-прежнему решает день, но + случайность в них больше не одинакова для всех. Плата + названа: воронка живёт в коде #39, поэтому правка выходит за исходные + границы тикета и сдвигает трафиковый поток. Сейчас это ничего не стоит — + манифест ещё не собран (#42); позже обошлось бы его пересборкой. + Отклонено: *оставить как есть* — числа сходятся, но «покупатель» остаётся + именем без следа в данных; *покупают только помеченные* — 5% людей дают + около 40 заказов в день вместо 220; вернуть их можно, лишь подняв долю + покупателей до 10–30% людей, а с ней вырастет число двухкуковых пар и + измеренные числа #38 придётся пересчитывать. +- **Событие корзины садится на карточку товара**, событие покупки — на + страницу подтверждения. В жизни событие корзины шлётся нажатием кнопки на + карточке, а не открытием страницы корзины; при нескольких товарах в заказе + иначе его и не разложить — событий столько, со скольких карточек положили. +- **Номер заказа читаемый**: день модельного времени и порядковый номер + покупки в этом дне. Магазины так и нумеруют, и по номеру сразу видно день. + Он же — `order_id` бэкенда: обе стороны получают один номер, по нему + соединяется сверка (мастер-спека, раздел 4). +- **Промокод в событии есть, в сумме его нет.** Часть заказов уходит с + промокодом, но выручка, которую шлёт клиент, — сумма позиций без скидки и + доставки. Так и в жизни: код на сайте знает корзину, а не итог расчёта. Это + ровно то расхождение с бэкендом, которое менти будет раскапывать + (мастер-спека, раздел 4). Отклонено: *промокода нет никогда* — колонка + формата осталась бы мёртвой. +- **Деньги: копейки внутри, рубли в колонках, дробь только в выручке.** + Генератор считает целыми копейками; цена товара в событии — целые рубли, + как у Метрики (`productPrice` там `Int64`); дробное число одно — + `purchaseRevenue` типа `Float64`. Деление на сто верно, лишь пока цены + каталога кратны рублю: это сторожит тест, иначе копейки утекали бы молча. +- **Массивы `purchase*` и `product*` разной длины между собой.** Внутри своей + группы длина общая: `purchase*` — по элементу на заказ (у нас всегда один), + `product*` — по элементу на товар. Мастер-спека (раздел 1) говорит о + «параллельных массивах одной длины», и прочесть это как «все массивы + события одной длины» легко — поэтому в описании выгрузки сказано прямо. +- **Сырой `ecommerce` собирает канонический сериализатор.** Поле — строка + JSON внутри события, и собирать её руками значит писать второй + сериализатор со своим экранированием. Зависимость `orjson`, названная + рабочим выбором в разделе 4, появляется здесь, а не в #41. +- **Хвост покупки не выходит за полночь.** Событие покупки встаёт на + несколько секунд позже просмотра страницы подтверждения, а граница суток + режет всё, что за неё вышло. Визит с обещанным планом заказом уже подвинут + так, чтобы уместиться в сутки (#39), но подвинут по страницам — торгового + хвоста в том расчёте не было. Хвост прижимается к последней секунде суток; + иначе гарантия двухкуковых пар порвалась бы молча. + Остаётся открытым, за тикетами: - интерфейс запуска генератора (CLI / цели make) и как он делит режимы -- 2.54.0 From 2cfda7180fb4652a3945f120b92a2815a93229cf Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Sun, 2 Aug 2026 17:37:48 +0300 Subject: [PATCH 2/8] =?UTF-8?q?docs(generator):=20=D1=80=D0=B5=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20#40=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20?= =?UTF-8?q?=D1=85=D0=BE=D0=BB=D0=BE=D0=B4=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B2=D1=8C=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - холодное ревью нашло две дыры уровня решений: лифт конверсии не делает покупателя видимым в данных, а цены каталога кратны рублю — от этого урок про Float64 остаётся без материала. - Что: - метка покупателя получает второй рычаг: помеченные дольше живут и чаще возвращаются; границы правки заданы вилками раздела 5, база до правок записана числами. - часть цен каталога получит копейки; хвост этапу 4 переписан честнее. - дописаны нерешённые места: количество штук и повторная карточка, множество нумерации заказов, таблица «код — скидка», состав сырого ecommerce, резерв времени вместо зажима к полуночи. - два расхождения внесены в мастер-спеку и записаны в раздел 7: длина массивов purchase* и product*, механика класса amount_delta. - Проверка: - чтением: docs/specs/2026-08-01-generator.md, разделы 7-9; docs/specs/2026-07-30-stand-v2-realism.md, разделы 1, 4, 7. --- docs/specs/2026-07-30-stand-v2-realism.md | 11 +- docs/specs/2026-08-01-generator.md | 151 ++++++++++++++-------- 2 files changed, 104 insertions(+), 58 deletions(-) diff --git a/docs/specs/2026-07-30-stand-v2-realism.md b/docs/specs/2026-07-30-stand-v2-realism.md index 32c2d87..4ef87ef 100644 --- a/docs/specs/2026-07-30-stand-v2-realism.md +++ b/docs/specs/2026-07-30-stand-v2-realism.md @@ -48,8 +48,10 @@ ## 1. Широкое событие кликстрима Форма — хит Метрики из облачной выгрузки: одно событие = одна строка, -многозначное — в параллельных массивах одной длины, плюс одно сырое -JSON-поле `ecommerce`. Сессий в потоке нет — их менти собирает сам в DDS. +многозначное — в параллельных массивах, плюс одно сырое JSON-поле +`ecommerce`. Одна длина у массивов общая внутри группы: `purchase*` — по +элементу на заказ, `product*` — по элементу на товар; между собой группы +разной длины. Сессий в потоке нет — их менти собирает сам в DDS. ### 1.1 Решения по именам и типам @@ -239,7 +241,7 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate |---|---|---|---| | A | Отмена | заказ дошёл до `cancelled`, `purchase` остался | ~5% заказов | | B | Потерянное событие | заказ есть, `purchase` не доехал | ~3% | -| C | Дельта суммы | сверка приведена к сравнимой базе (`items_total`, не `total`); `amount_delta` — только необъяснённый остаток после этого: округления Float64, вероятность в генераторе | ~1–2% | +| C | Дельта суммы | сверка приведена к сравнимой базе (`items_total`, не `total`); `amount_delta` — только необъяснённый остаток после этого, и создаёт его генератор намеренно: деньги считаются целыми копейками, поэтому Float64 сам по себе не плывёт | ~1–2% | | D | Дубль события | повторный `purchase` от обновления `/confirmation`: новый `WatchID` с тем же `purchaseID` — бизнес-дубль, не технический; дедуп ReplacingMergeTree его не съедает и не должен | ~2% | Классы пересекаются — приоритет: `cancelled` > `lost_event` > @@ -405,7 +407,8 @@ Kafka день переигрывается генератором заново: (`match` / `cancelled` / `lost_event` / `duplicate_event` / `amount_delta`, в порядке приоритета — классы пересекаются, побеждает более ранний). `match` — большинство строк; `amount_delta` — только необъяснённый остаток - после приведения к сравнимой базе (округления Float64, ~1–2% заказов). + после приведения к сравнимой базе; его создаёт генератор намеренно + (~1–2% заказов, см. раздел 4). Строка «`purchase` без заказа» внутри живого окна — опоздание, ждущее слепка, а не расхождение: она получает служебный класс `awaiting_order` (шестое значение `mismatch_class`, вне приоритетов расхождений). После diff --git a/docs/specs/2026-08-01-generator.md b/docs/specs/2026-08-01-generator.md index 138bada..3c1aa6c 100644 --- a/docs/specs/2026-08-01-generator.md +++ b/docs/specs/2026-08-01-generator.md @@ -388,15 +388,26 @@ pytest-тест с маркером `perf` и таймаутом-обрубан переобработки при исчерпании retention Kafka (раздел 7), формулировка этапа 7 (раздел 9). +Внесены при исполнении #40 (2026-08-02): + +- **Раздел 1**: «в параллельных массивах одной длины» уточнено — длина общая + внутри группы, а `purchase*` и `product*` между собой разной длины. +- **Разделы 4 и 7**: механика класса `amount_delta` названа честнее. + Округления `Float64` сами по себе расхождения не дают: деньги считаются + целыми копейками, и дробное число в событии одно. Класс создаёт генератор + намеренно — так мастер-спека и допускала вторым вариантом. + ## 8. Хвосты следующим этапам - **Этап 3 (заказы бэкенда)**: заказу, у которого в клиентском событии стоит промокод, бэкенд обязан дать скидку — иначе данные соврут. Номер заказа обе стороны берут один и тот же (раздел 9). -- **Этап 4 (сверка)**: расхождение сумм само не появится — цены каталога - кратны рублю, и `Float64` на таких числах не плывёт. Класс `amount_delta` - придётся создавать намеренно, вероятностью в генераторе, — мастер-спека - (раздел 4) это и допускает. +- **Этап 4 (сверка)**: копейки в ценах каталога дают разрыв внутри события — + `productPrice` округлён форматом, `purchaseRevenue` точна. В сверку он сам + не попадает: клиент шлёт точную сумму, и она сходится с `items_total` + бэкенда. Класс `amount_delta` этап 4 всё равно создаёт намеренно — + мастер-спека (раздел 4) это и допускает; материал для разговора про Float64 + теперь есть. - **Этап 5 (Airflow)**: живой день со стороны хранилища — обычный ETL-даг по расписанию (~раз в 24 минуты); генератор не дорабатывается. - **Этап 7 (эталонный мир)**: пересборка — это манифест, не артефакт; @@ -549,72 +560,104 @@ pytest-тест с маркером `perf` и таймаутом-обрубан `/confirmation`, а перед корзиной у него всегда есть карточка. Своей случайности #39 у #40 не занимает: подпоток `COMMERCE` не тронут. -Решено при исполнении #40 (2026-08-02) — решения владельца до реализации: +Решено при исполнении #40 (2026-08-02) — решения владельца до реализации. +Три решения выходят за границы тикета: правятся план состава (#38), воронка +(#39) и каталог товаров (#39). Сейчас это дёшево — манифест ещё не собран +(#42); позже обошлось бы его пересборкой. Часть решений принята после +холодного ревью первой редакции блока. - **Корзина шире заказа.** Посетитель кладёт в корзину товары тех карточек, которые открывал в этом визите, а покупает не всё: около 15% позиций остаются брошенными. Иначе событие корзины не рассказывало бы ничего сверх покупки — заказ был бы её точной копией, и сравнивать было бы нечего. - Замерено на каноническом мире до правки воронки (ниже): у покупающего - визита 2,1 карточки, в заказе - выходит 1,9 позиции, однопозиционных заказов около половины. Отклонено: - *заказ всегда из одного товара* — список позиций как урок умирает, а на нём - стоит единственный носитель навыка «вложенный JSON» (мастер-спека, - раздел 2); *досыпать в корзину товары, которых посетитель не открывал* — - ломает шов #39 ровно там, ради чего он строился; *бросать много* (40% и - выше) — две трети заказов схлопываются в один товар. -- **Покупатель перестаёт быть словом.** Метка плана — «этот человек склонен - покупать», 5% людей когорты — до сих пор ни на что не влияла: кто купит, - решал одинаковый для всех бросок в дне. Свойство человека в данных не - читалось, постоянных покупателей в мире не было. Теперь метка правит - воронкой: помеченный доходит до заказа заметно чаще прочих, но и - непомеченный иногда покупает. Общее число покупок и общая доля визитов с - корзиной остаются прежними — меняется лишь то, кто эти визиты совершает; - точные доли — калибровка при реализации, требование сторожит тест. - Уточнение к разделу 1: вольные покупки по-прежнему решает день, но - случайность в них больше не одинакова для всех. Плата - названа: воронка живёт в коде #39, поэтому правка выходит за исходные - границы тикета и сдвигает трафиковый поток. Сейчас это ничего не стоит — - манифест ещё не собран (#42); позже обошлось бы его пересборкой. - Отклонено: *оставить как есть* — числа сходятся, но «покупатель» остаётся - именем без следа в данных; *покупают только помеченные* — 5% людей дают - около 40 заказов в день вместо 220; вернуть их можно, лишь подняв долю - покупателей до 10–30% людей, а с ней вырастет число двухкуковых пар и - измеренные числа #38 придётся пересчитывать. + Замерено на мире до правок: у покупающего визита 2,1 карточки, в заказе + выходит 1,9 позиции, однопозиционных заказов около половины. Карточка, + открытая в визите дважды, даёт одну позицию, а не две: повторный просмотр — + раздумье, а не второй товар. Количество штук — свой бросок: обычно одна, + изредка две-три. Отклонено: *заказ всегда из одного товара* — список + позиций как урок умирает, а на нём стоит единственный носитель навыка + «вложенный JSON» (мастер-спека, раздел 2); *досыпать в корзину товары, + которых посетитель не открывал* — ломает шов #39 ровно там, ради чего он + строился; *бросать много* (40% и выше) — две трети заказов схлопываются в + один товар. +- **Покупатель перестаёт быть словом — двумя рычагами сразу.** Метка плана + («этот человек склонен покупать», 5% людей когорты) до сих пор ни на что не + влияла: кто купит, решал одинаковый для всех бросок в дне. Теперь + помеченный, во-первых, доходит до заказа заметно чаще прочих, во-вторых, + дольше живёт и чаще возвращается. Первый рычаг — про правдоподобие: + склонность покупать и вправду свойство человека, а не свойство визита. + Второй — про видимость: метки в событии нет и не будет (кликстрим + анонимен), поэтому «постоянный покупатель» читается в данных только как + кука, которая ходит неделями и покупает не раз. Одним лифтом это + недостижимо: кука живёт меньше двух визитов за снимок, и второй покупке + негде случиться — лифт поднял бы долю кук с двумя покупками с 2% до 6% и на + том исчерпался. Рычаг жизни лежит в плане (#38), рычаг конверсии — в + воронке (#39); правятся оба. Границы правки — вилки раздела 5: дневная + аудитория остаётся в пределах 6–8 тыс., средний день — около 50 тыс. + событий, конверсия визита — около 2%. Расти внутри вилки аудитории + разрешено: помеченные ходят чаще, и это по-прежнему правдоподобный магазин. + Измеренные числа состава (блок #38 выше) после правки перемеряются и + переписываются; база до правки — в средний день аудитория 6 710, визитов + 9 249, событий 44 436, визитов с корзиной 780 (8,4%) и с покупкой 221 + (2,4%). Уточнение к разделу 1: вольные покупки по-прежнему решает день, но + случайность в них больше не одинакова для всех. Отклонено: *оставить + монетку* — «покупатель» остаётся именем без следа в данных; *один лифт без + долгой жизни* — довод выше, повторных покупок он почти не прибавляет; + *покупают только помеченные* — 5% людей дают около 40 заказов в день вместо + 220, вернуть их можно, лишь подняв долю покупателей до 10–30% людей, а с + ней вырастет и число двухкуковых пар. - **Событие корзины садится на карточку товара**, событие покупки — на страницу подтверждения. В жизни событие корзины шлётся нажатием кнопки на карточке, а не открытием страницы корзины; при нескольких товарах в заказе иначе его и не разложить — событий столько, со скольких карточек положили. - **Номер заказа читаемый**: день модельного времени и порядковый номер - покупки в этом дне. Магазины так и нумеруют, и по номеру сразу видно день. - Он же — `order_id` бэкенда: обе стороны получают один номер, по нему - соединяется сверка (мастер-спека, раздел 4). -- **Промокод в событии есть, в сумме его нет.** Часть заказов уходит с + покупки в этом дне (`20260603-0042`). Магазины так и нумеруют, и по номеру + сразу видно день. Он же — `order_id` бэкенда: обе стороны получают один + номер, по нему соединяется сверка (мастер-спека, раздел 4). Нумеруются все + покупки, дошедшие до потока дня, в порядке событий — до всяких потерь. + Расхождения этапа 6 выбрасывают событие, когда номер уже присвоен: иначе + одна потеря перенумеровала бы чужие заказы, и мост к бэкенду разъехался бы. +- **Промокод в событии есть, скидки в сумме нет.** Часть заказов уходит с промокодом, но выручка, которую шлёт клиент, — сумма позиций без скидки и - доставки. Так и в жизни: код на сайте знает корзину, а не итог расчёта. Это - ровно то расхождение с бэкендом, которое менти будет раскапывать - (мастер-спека, раздел 4). Отклонено: *промокода нет никогда* — колонка - формата осталась бы мёртвой. -- **Деньги: копейки внутри, рубли в колонках, дробь только в выручке.** - Генератор считает целыми копейками; цена товара в событии — целые рубли, - как у Метрики (`productPrice` там `Int64`); дробное число одно — - `purchaseRevenue` типа `Float64`. Деление на сто верно, лишь пока цены - каталога кратны рублю: это сторожит тест, иначе копейки утекали бы молча. + доставки. Так и в жизни: код на сайте знает корзину, а не итог расчёта. + Класса расхождения промокод не даёт: сверка приведена к `items_total`, где + скидки и нет (мастер-спека, раздел 7). Он объясняет разрыв между клиентской + суммой и итогом заказа — одну из причин, по которым «суммы не сойдутся» + (мастер-спека, раздел 4). Таблица «код → скидка» — число мира и живёт + здесь: этап 3 берёт её готовой, а не сочиняет заново. Отклонено: + *промокода нет никогда* — колонка формата осталась бы мёртвой. +- **Деньги: копейки внутри, рубли в колонках, дробь только в выручке — и + копейки в части цен каталога.** Генератор считает целыми копейками; цена + товара в событии — целые рубли, как у Метрики (`productPrice` там `Int64`); + дробное число одно — `purchaseRevenue` типа `Float64`. Все 180 цен каталога + кратны рублю, и это делало урок про Float64 беспредметным: округлять + нечего, копейки существуют только на словах, а расхождение сумм пришлось бы + выдумывать вероятностью. Поэтому часть цен получает копейки (1 299,90 ₽ — + обычная розница): `productPrice` округляется самим форматом, а + `purchaseRevenue` несёт точную сумму. Разрыв живёт внутри события — считать + выручку по разобранным массивам нельзя, и это настоящий урок формата, а не + придуманный. - **Массивы `purchase*` и `product*` разной длины между собой.** Внутри своей группы длина общая: `purchase*` — по элементу на заказ (у нас всегда один), `product*` — по элементу на товар. Мастер-спека (раздел 1) говорит о «параллельных массивах одной длины», и прочесть это как «все массивы события одной длины» легко — поэтому в описании выгрузки сказано прямо. -- **Сырой `ecommerce` собирает канонический сериализатор.** Поле — строка - JSON внутри события, и собирать её руками значит писать второй - сериализатор со своим экранированием. Зависимость `orjson`, названная - рабочим выбором в разделе 4, появляется здесь, а не в #41. -- **Хвост покупки не выходит за полночь.** Событие покупки встаёт на - несколько секунд позже просмотра страницы подтверждения, а граница суток - режет всё, что за неё вышло. Визит с обещанным планом заказом уже подвинут - так, чтобы уместиться в сутки (#39), но подвинут по страницам — торгового - хвоста в том расчёте не было. Хвост прижимается к последней секунде суток; - иначе гарантия двухкуковых пар порвалась бы молча. +- **Сырой `ecommerce` несёт больше, чем плоские колонки.** Лаба «сырое против + разобранного» (мастер-спека, раздел 1.2) имеет смысл, только если в сыром + лежит то, чего в массивах нет: бренд товара, его вариант, блок + `actionField` целиком. Собирает строку канонический сериализатор — руками + это второй сериализатор со своим экранированием; зависимость `orjson`, + названная рабочим выбором в разделе 4, появляется здесь, а не в #41. +- **Торговый хвост умещается в сутки резервом, а не зажимом.** Событие + покупки встаёт на несколько секунд позже просмотра страницы подтверждения, + событие корзины — позже своей карточки, а граница суток режет всё, что за + неё вышло. Визит с обещанным планом заказом уже подвинут так, чтобы + уместиться в сутки (#39), но подвинут по страницам — торгового хвоста в том + расчёте не было. Резерв расширяется на хвост: тогда правило «покупка на + секунды позже подтверждения» держится всегда, а зажим к последней секунде + ломал бы его ровно там, ради чего написан. Визит, у которого страница + подтверждения срезана полуночью (1,2% покупающих), покупки не даёт — заказа + не было. Остаётся открытым, за тикетами: -- 2.54.0 From bfbaa96696f4a9d7d2227650ab6a0594458c7d78 Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Sun, 2 Aug 2026 17:42:48 +0300 Subject: [PATCH 3/8] =?UTF-8?q?docs(generator):=20=D1=84=D0=BE=D1=80=D0=BC?= =?UTF-8?q?=D0=B0=20=D1=8D=D1=84=D1=84=D0=B5=D0=BA=D1=82=D0=B0=20=D0=BC?= =?UTF-8?q?=D0=B5=D1=82=D0=BA=D0=B8=20=D0=BF=D0=BE=D0=BA=D1=83=D0=BF=D0=B0?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D1=8F=20=E2=80=94=20=D0=BE=D0=B1=D0=B0=20?= =?UTF-8?q?=D1=88=D0=B0=D0=B3=D0=B0=20=D0=B2=D0=BE=D1=80=D0=BE=D0=BD=D0=BA?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - холодное ревью отметило, что это решение, а не калибровка: от выбора шага зависит, как «покупатель» читается в данных, и оставлять выбор реализации нельзя. - Что: - в блоке решений #40 записано, что помеченный отличается на обоих шагах воронки — и до корзины доходит чаще, и бросает её реже; числа под каждый шаг остаются калибровкой. - Проверка: - чтением: docs/specs/2026-08-01-generator.md, раздел 9, блок #40. --- docs/specs/2026-08-01-generator.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/specs/2026-08-01-generator.md b/docs/specs/2026-08-01-generator.md index 3c1aa6c..158bd2d 100644 --- a/docs/specs/2026-08-01-generator.md +++ b/docs/specs/2026-08-01-generator.md @@ -592,7 +592,11 @@ pytest-тест с маркером `perf` и таймаутом-обрубан недостижимо: кука живёт меньше двух визитов за снимок, и второй покупке негде случиться — лифт поднял бы долю кук с двумя покупками с 2% до 6% и на том исчерпался. Рычаг жизни лежит в плане (#38), рычаг конверсии — в - воронке (#39); правятся оба. Границы правки — вилки раздела 5: дневная + воронке (#39); правятся оба. Отличается помеченный на обоих шагах воронки: + и до корзины доходит чаще, и бросает её реже. В жизни различаются оба — кто + пришёл смотреть, тот и кладёт реже, и до конца доводит реже; один шаг дал + бы половину картины. Числа под каждый шаг — калибровка. Границы правки — + вилки раздела 5: дневная аудитория остаётся в пределах 6–8 тыс., средний день — около 50 тыс. событий, конверсия визита — около 2%. Расти внутри вилки аудитории разрешено: помеченные ходят чаще, и это по-прежнему правдоподобный магазин. -- 2.54.0 From 722dbe22b715eb5d2fc1810f0a8c40634491642b Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Sun, 2 Aug 2026 19:13:30 +0300 Subject: [PATCH 4/8] =?UTF-8?q?feat(generator):=20=D1=82=D0=BE=D1=80=D0=B3?= =?UTF-8?q?=D0=BE=D0=B2=D1=8B=D0=B5=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8?= =?UTF-8?q?=D1=8F=20=E2=80=94=20=D0=BA=D0=BE=D1=80=D0=B7=D0=B8=D0=BD=D0=B0?= =?UTF-8?q?,=20=D0=BF=D0=BE=D0=BA=D1=83=D0=BF=D0=BA=D0=B0,=20=D1=81=D1=8B?= =?UTF-8?q?=D1=80=D0=BE=D0=B9=20ecommerce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - клиентская сторона мира становится целой: без add_to_cart и purchase в данных нет ни таксономии событий, ни вложенного JSON, ни денег, а метка «покупатель» из плана состава ни на что не влияла (#40). - Что: - добавлен модуль commerce: корзина шире заказа, номер заказа вида ГГГГММДД-NNNN, промокод без скидки в сумме, сырой ecommerce через orjson; - метка покупателя получила два рычага — долгую жизнь куки в плане и свою воронку в дне; CART_PERCENT опущен с 8 до 6, чтобы конверсия мира осталась около 2%; - часть цен каталога получила копейки: productPrice округляется форматом, purchaseRevenue несёт точную сумму — расхождение живёт внутри события; - граница суток забирает страницу подтверждения вместе с её покупкой: потерь на клиентской стороне этот этап не заводит; - решения и перемеренные числа мира записаны в спеку генератора, §9. - Проверка: - make lint && make typecheck && make test — 383 passed (было 353). Co-Authored-By: Claude Opus 5 --- data/catalog/products.csv | 150 +++--- docs/formats/clickstream-event.md | 10 +- docs/specs/2026-08-01-generator.md | 96 +++- generator/README.md | 10 +- generator/pyproject.toml | 5 +- .../src/clickstream_generator/catalog.py | 4 + .../src/clickstream_generator/commerce.py | 500 ++++++++++++++++++ generator/src/clickstream_generator/day.py | 142 +++-- generator/src/clickstream_generator/ids.py | 47 ++ generator/src/clickstream_generator/plan.py | 47 +- .../src/clickstream_generator/reference.py | 13 + .../src/clickstream_generator/schema_doc.py | 10 +- generator/src/clickstream_generator/world.py | 65 ++- generator/tests/test_catalog.py | 12 + generator/tests/test_commerce.py | 488 +++++++++++++++++ generator/tests/test_day.py | 64 ++- generator/tests/test_ids.py | 41 ++ generator/tests/test_world.py | 68 ++- generator/uv.lock | 29 +- 19 files changed, 1591 insertions(+), 210 deletions(-) create mode 100644 generator/src/clickstream_generator/commerce.py create mode 100644 generator/src/clickstream_generator/ids.py create mode 100644 generator/tests/test_commerce.py create mode 100644 generator/tests/test_ids.py diff --git a/data/catalog/products.csv b/data/catalog/products.csv index 4f5f017..5f72864 100644 --- a/data/catalog/products.csv +++ b/data/catalog/products.csv @@ -1,60 +1,60 @@ sku,name,category,brand,price HOME-0001,Комплект органайзеров для шкафа,Товары для дома,Домовит,129000 HOME-0002,Сушилка для белья напольная,Товары для дома,Домовит,249900 -HOME-0003,Настольная лампа «Тихий вечер»,Товары для дома,Ладный дом,189000 +HOME-0003,Настольная лампа «Тихий вечер»,Товары для дома,Ладный дом,188990 HOME-0004,Набор вешалок 12 штук,Товары для дома,Домовит,79000 -TEXT-0001,Комплект постельного белья полутораспальный,Текстиль,Сатин,349000 +TEXT-0001,Комплект постельного белья полутораспальный,Текстиль,Сатин,348990 TEXT-0002,Плед-покрывало 200×220,Текстиль,Сатин,279000 TEXT-0003,Полотенце банное 70×140,Текстиль,Сатин,89000 -TEXT-0004,Штора блэкаут 200×260,Текстиль,Ладный дом,219000 -POSU-0001,Сковорода 26 см с антипригарным покрытием,Посуда,Кухмастер,199000 -POSU-0002,Набор кастрюль 3 предмета,Посуда,Кухмастер,459000 -POSU-0003,Чайник заварочный стеклянный 1 л,Посуда,Кухмастер,119000 +TEXT-0004,Штора блэкаут 200×260,Текстиль,Ладный дом,218990 +POSU-0001,Сковорода 26 см с антипригарным покрытием,Посуда,Кухмастер,198990 +POSU-0002,Набор кастрюль 3 предмета,Посуда,Кухмастер,458999 +POSU-0003,Чайник заварочный стеклянный 1 л,Посуда,Кухмастер,118990 POSU-0004,Набор столовых приборов 24 предмета,Посуда,Ладья,289000 -TECH-0001,Электрочайник 1.7 л,Бытовая техника,Электрон,189000 +TECH-0001,Электрочайник 1.7 л,Бытовая техника,Электрон,188990 TECH-0002,Блендер погружной 800 Вт,Бытовая техника,Электрон,279000 TECH-0003,Отпариватель ручной,Бытовая техника,Электрон,349000 TECH-0004,Робот-пылесос,Бытовая техника,Электрон,1899000 KIDS-0001,Стульчик для кормления,Детские товары,Малышок,649000 -KIDS-0002,Ночник-проектор «Звёзды»,Детские товары,Малышок,129000 -KIDS-0003,Набор детской посуды 5 предметов,Детские товары,Малышок,89000 -WEAR-0001,Тапочки домашние войлочные,Одежда и обувь,Ходики,99000 +KIDS-0002,Ночник-проектор «Звёзды»,Детские товары,Малышок,128990 +KIDS-0003,Набор детской посуды 5 предметов,Детские товары,Малышок,88999 +WEAR-0001,Тапочки домашние войлочные,Одежда и обувь,Ходики,98990 WEAR-0002,Халат махровый,Одежда и обувь,Сатин,249000 WEAR-0003,Носки хлопковые 5 пар,Одежда и обувь,Ходики,59000 HOME-0005,Корзина для белья 45 л,Товары для дома,Домовит,119000 HOME-0006,Контейнер для хранения 18 л,Товары для дома,Домовит,69000 -HOME-0007,Чехол для одежды 60×100,Товары для дома,Ладный дом,39000 +HOME-0007,Чехол для одежды 60×100,Товары для дома,Ладный дом,38990 HOME-0008,Коврик придверный резиновый 40×60,Товары для дома,Домовит,59000 HOME-0009,Гладильная доска настольная 73×30,Товары для дома,Ладный дом,149000 -HOME-0010,Ведро хозяйственное с крышкой 12 л,Товары для дома,Домовит,49000 -HOME-0011,Швабра с отжимом и ведром 8 л,Товары для дома,Домовит,179000 +HOME-0010,Ведро хозяйственное с крышкой 12 л,Товары для дома,Домовит,48990 +HOME-0011,Швабра с отжимом и ведром 8 л,Товары для дома,Домовит,178990 HOME-0012,Щётка для пола с черенком 120 см,Товары для дома,Домовит,45000 HOME-0013,Совок со щёткой на длинной ручке,Товары для дома,Домовит,79000 -HOME-0014,Таз хозяйственный круглый 14 л,Товары для дома,Домовит,39000 +HOME-0014,Таз хозяйственный круглый 14 л,Товары для дома,Домовит,38990 HOME-0015,Этажерка для ванной 3 яруса,Товары для дома,Ладный дом,139000 HOME-0016,Полка настенная металлическая 45 см,Товары для дома,Ладный дом,99000 -HOME-0017,Зеркало настенное круглое 50 см,Товары для дома,Ладный дом,169000 +HOME-0017,Зеркало настенное круглое 50 см,Товары для дома,Ладный дом,168990 HOME-0018,Набор коробок для обуви 6 штук,Товары для дома,Домовит,129000 -HOME-0019,Подставка для зонтов металлическая,Товары для дома,Ладный дом,159000 +HOME-0019,Подставка для зонтов металлическая,Товары для дома,Ладный дом,158990 HOME-0020,Крючки самоклеящиеся 8 штук,Товары для дома,Домовит,29000 HOME-0021,Верёвка бельевая 20 м,Товары для дома,Домовит,19000 -HOME-0022,Прищепки усиленные 24 штуки,Товары для дома,Домовит,25000 -HOME-0023,Мешки вакуумные для одежды 5 штук,Товары для дома,Домовит,69000 +HOME-0022,Прищепки усиленные 24 штуки,Товары для дома,Домовит,24990 +HOME-0023,Мешки вакуумные для одежды 5 штук,Товары для дома,Домовит,68999 HOME-0024,Органайзер подвесной с 12 карманами,Товары для дома,Ладный дом,79000 -HOME-0025,Часы настенные круглые 30 см,Товары для дома,Ладный дом,89000 +HOME-0025,Часы настенные круглые 30 см,Товары для дома,Ладный дом,88990 HOME-0026,Светильник настольный с прищепкой 7 Вт,Товары для дома,Ладный дом,119000 HOME-0027,Удлинитель бытовой 3 розетки 3 м,Товары для дома,Ладный дом,99000 HOME-0028,Лестница-стремянка 3 ступени,Товары для дома,Домовит,299000 -HOME-0029,Ящик для инструментов 16 дюймов,Товары для дома,Домовит,139000 +HOME-0029,Ящик для инструментов 16 дюймов,Товары для дома,Домовит,138990 HOME-0030,Набор плечиков для брюк 6 штук,Товары для дома,Домовит,89000 TEXT-0005,Пододеяльник двуспальный 175×215,Текстиль,Сатин,169000 -TEXT-0006,Простыня на резинке 160×200,Текстиль,Сатин,149000 +TEXT-0006,Простыня на резинке 160×200,Текстиль,Сатин,148990 TEXT-0007,Наволочки хлопковые 50×70 2 штуки,Текстиль,Сатин,79000 TEXT-0008,Одеяло всесезонное 172×205,Текстиль,Сатин,299000 -TEXT-0009,Подушка стёганая 50×70,Текстиль,Сатин,129000 +TEXT-0009,Подушка стёганая 50×70,Текстиль,Сатин,128990 TEXT-0010,Наматрасник непромокаемый 160×200,Текстиль,Сатин,189000 -TEXT-0011,Покрывало стёганое 180×210,Текстиль,Сатин,239000 -TEXT-0012,Плед флисовый 150×200,Текстиль,Сатин,119000 +TEXT-0011,Покрывало стёганое 180×210,Текстиль,Сатин,238999 +TEXT-0012,Плед флисовый 150×200,Текстиль,Сатин,118990 TEXT-0013,Полотенце лицевое 50×90,Текстиль,Сатин,49000 TEXT-0014,Полотенца кухонные 40×60 3 штуки,Текстиль,Сатин,59000 TEXT-0015,Коврик для ванной хлопковый 50×80,Текстиль,Ладный дом,99000 @@ -64,47 +64,47 @@ TEXT-0018,Шторы кухонные 150×170 2 полотна,Текстиль TEXT-0019,Скатерть водоотталкивающая 140×180,Текстиль,Ладный дом,129000 TEXT-0020,Дорожка на стол хлопковая 40×140,Текстиль,Ладный дом,59000 TEXT-0021,Салфетки столовые льняные 4 штуки,Текстиль,Сатин,79000 -TEXT-0022,Чехлы на табурет 35×35 4 штуки,Текстиль,Ладный дом,109000 -TEXT-0023,Подушка декоративная 40×40,Текстиль,Сатин,69000 +TEXT-0022,Чехлы на табурет 35×35 4 штуки,Текстиль,Ладный дом,108990 +TEXT-0023,Подушка декоративная 40×40,Текстиль,Сатин,68999 TEXT-0024,Чехол для диванной подушки 45×45,Текстиль,Сатин,39000 TEXT-0025,Фартук кухонный с карманом,Текстиль,Ладный дом,59000 -TEXT-0026,Прихватки стёганые 20×20 2 штуки,Текстиль,Ладный дом,39000 +TEXT-0026,Прихватки стёганые 20×20 2 штуки,Текстиль,Ладный дом,38990 TEXT-0027,Халат вафельный размер 48–50,Текстиль,Сатин,199000 -TEXT-0028,Комплект полотенец 50×90 и 70×140,Текстиль,Сатин,139000 +TEXT-0028,Комплект полотенец 50×90 и 70×140,Текстиль,Сатин,138999 TEXT-0029,Одеяло детское байковое 100×140,Текстиль,Сатин,169000 TEXT-0030,Подушка для стула 40×40,Текстиль,Ладный дом,59000 -POSU-0005,Кастрюля эмалированная 3 л,Посуда,Кухмастер,169000 -POSU-0006,Ковш нержавеющий с крышкой 1.5 л,Посуда,Кухмастер,139000 +POSU-0005,Кастрюля эмалированная 3 л,Посуда,Кухмастер,168990 +POSU-0006,Ковш нержавеющий с крышкой 1.5 л,Посуда,Кухмастер,138990 POSU-0007,Сотейник литой с крышкой 28 см,Посуда,Кухмастер,269000 POSU-0008,Сковорода блинная 22 см,Посуда,Кухмастер,149000 POSU-0009,Казан алюминиевый 6 л,Посуда,Кухмастер,299000 -POSU-0010,Форма для запекания стеклянная 2.4 л,Посуда,Кухмастер,119000 +POSU-0010,Форма для запекания стеклянная 2.4 л,Посуда,Кухмастер,118990 POSU-0011,Противень стальной 37×27,Посуда,Кухмастер,79000 POSU-0012,Набор форм для выпечки 3 штуки,Посуда,Кухмастер,139000 -POSU-0013,Миска нержавеющая 24 см,Посуда,Кухмастер,69000 +POSU-0013,Миска нержавеющая 24 см,Посуда,Кухмастер,68990 POSU-0014,Дуршлаг стальной с ручками 22 см,Посуда,Кухмастер,89000 POSU-0015,Тёрка четырёхгранная 23 см,Посуда,Кухмастер,49000 -POSU-0016,Доска разделочная бамбуковая 35×25,Посуда,Кухмастер,79000 -POSU-0017,Набор ножей с подставкой 6 предметов,Посуда,Кухмастер,249000 -POSU-0018,Половник и шумовка 2 предмета,Посуда,Кухмастер,59000 +POSU-0016,Доска разделочная бамбуковая 35×25,Посуда,Кухмастер,78990 +POSU-0017,Набор ножей с подставкой 6 предметов,Посуда,Кухмастер,248990 +POSU-0018,Половник и шумовка 2 предмета,Посуда,Кухмастер,58990 POSU-0019,Контейнеры пищевые 7 предметов,Посуда,Кухмастер,99000 -POSU-0020,Банки для сыпучих продуктов 4 штуки,Посуда,Ладья,139000 -POSU-0021,Тарелки обеденные фарфоровые 6 штук,Посуда,Ладья,179000 +POSU-0020,Банки для сыпучих продуктов 4 штуки,Посуда,Ладья,138990 +POSU-0021,Тарелки обеденные фарфоровые 6 штук,Посуда,Ладья,178999 POSU-0022,Тарелки суповые керамические 4 штуки,Посуда,Ладья,129000 -POSU-0023,Салатник стеклянный 23 см,Посуда,Ладья,69000 -POSU-0024,Кружки керамические 350 мл 4 штуки,Посуда,Ладья,119000 -POSU-0025,Стаканы высокие 400 мл 6 штук,Посуда,Ладья,99000 +POSU-0023,Салатник стеклянный 23 см,Посуда,Ладья,68999 +POSU-0024,Кружки керамические 350 мл 4 штуки,Посуда,Ладья,118990 +POSU-0025,Стаканы высокие 400 мл 6 штук,Посуда,Ладья,98990 POSU-0026,Бокалы для воды 300 мл 6 штук,Посуда,Ладья,149000 POSU-0027,Термос стальной 1 л,Посуда,Ладья,179000 POSU-0028,Термокружка с крышкой 450 мл,Посуда,Ладья,99000 POSU-0029,Сахарница фарфоровая 400 мл,Посуда,Ладья,59000 -POSU-0030,Маслёнка стеклянная с крышкой,Посуда,Ладья,69000 -TECH-0005,Миксер ручной 500 Вт,Бытовая техника,Электрон,249000 -TECH-0006,Измельчитель с чашей 1.5 л,Бытовая техника,Электрон,299000 +POSU-0030,Маслёнка стеклянная с крышкой,Посуда,Ладья,68990 +TECH-0005,Миксер ручной 500 Вт,Бытовая техника,Электрон,248990 +TECH-0006,Измельчитель с чашей 1.5 л,Бытовая техника,Электрон,298990 TECH-0007,Стационарный блендер 1.8 л,Бытовая техника,Электрон,549000 TECH-0008,Мясорубка электрическая 1800 Вт,Бытовая техника,Электрон,799000 TECH-0009,Тостер на 2 ломтика 850 Вт,Бытовая техника,Электрон,299000 -TECH-0010,Вафельница электрическая 1000 Вт,Бытовая техника,Электрон,349000 +TECH-0010,Вафельница электрическая 1000 Вт,Бытовая техника,Электрон,348999 TECH-0011,Мультиварка с чашей 5 л,Бытовая техника,Электрон,699000 TECH-0012,Электроплитка на 1 конфорку 1500 Вт,Бытовая техника,Электрон,249000 TECH-0013,Мини-печь с грилем 30 л,Бытовая техника,Электрон,899000 @@ -121,61 +121,61 @@ TECH-0023,Электровеник с контейнером 0.5 л,Бытова TECH-0024,Фен с диффузором 2200 Вт,Бытовая техника,Электрон,299000 TECH-0025,Щипцы для волос 45 Вт,Бытовая техника,Электрон,249000 TECH-0026,Машинка для стрижки с 6 насадками,Бытовая техника,Электрон,399000 -TECH-0027,Весы напольные до 180 кг,Бытовая техника,Электрон,199000 -TECH-0028,Весы кухонные до 5 кг,Бытовая техника,Электрон,149000 +TECH-0027,Весы напольные до 180 кг,Бытовая техника,Электрон,198990 +TECH-0028,Весы кухонные до 5 кг,Бытовая техника,Электрон,148990 TECH-0029,Увлажнитель воздуха 3 л,Бытовая техника,Электрон,499000 -TECH-0030,Тепловентилятор керамический 1500 Вт,Бытовая техника,Электрон,399000 -KIDS-0004,Кроватка кукольная деревянная 45 см,Детские товары,Малышок,249000 -KIDS-0005,Манеж складной 100×100,Детские товары,Малышок,799000 +TECH-0030,Тепловентилятор керамический 1500 Вт,Бытовая техника,Электрон,398990 +KIDS-0004,Кроватка кукольная деревянная 45 см,Детские товары,Малышок,248999 +KIDS-0005,Манеж складной 100×100,Детские товары,Малышок,798990 KIDS-0006,Горшок детский со спинкой,Детские товары,Малышок,79000 -KIDS-0007,Ванночка детская 84 см,Детские товары,Малышок,169000 +KIDS-0007,Ванночка детская 84 см,Детские товары,Малышок,168990 KIDS-0008,Подставка детская двухступенчатая,Детские товары,Малышок,89000 KIDS-0009,Накладка на унитаз с ручками,Детские товары,Малышок,69000 -KIDS-0010,Сиденье для купания на присосках,Детские товары,Малышок,139000 +KIDS-0010,Сиденье для купания на присосках,Детские товары,Малышок,138990 KIDS-0011,Пелёнки муслиновые 80×100 3 штуки,Детские товары,Малышок,129000 -KIDS-0012,Одеяло для коляски 90×110,Детские товары,Малышок,149000 -KIDS-0013,Нагрудники непромокаемые 5 штук,Детские товары,Малышок,59000 +KIDS-0012,Одеяло для коляски 90×110,Детские товары,Малышок,148990 +KIDS-0013,Нагрудники непромокаемые 5 штук,Детские товары,Малышок,58999 KIDS-0014,Бутылочка с ручками 250 мл,Детские товары,Малышок,49000 -KIDS-0015,Поильник непроливайка 300 мл,Детские товары,Малышок,55000 +KIDS-0015,Поильник непроливайка 300 мл,Детские товары,Малышок,54990 KIDS-0016,Контейнер для пустышки 2 штуки,Детские товары,Малышок,25000 KIDS-0017,Погремушки мягкие 3 штуки,Детские товары,Малышок,59000 KIDS-0018,Кубики развивающие 12 штук,Детские товары,Малышок,89000 -KIDS-0019,Пирамидка деревянная 8 колец,Детские товары,Малышок,79000 -KIDS-0020,Сортер с геометрическими фигурами,Детские товары,Малышок,99000 +KIDS-0019,Пирамидка деревянная 8 колец,Детские товары,Малышок,78990 +KIDS-0020,Сортер с геометрическими фигурами,Детские товары,Малышок,98990 KIDS-0021,Конструктор деревянный 50 деталей,Детские товары,Малышок,139000 -KIDS-0022,Доска для рисования магнитная 30×40,Детские товары,Малышок,129000 -KIDS-0023,Мольберт детский двусторонний 105 см,Детские товары,Малышок,299000 +KIDS-0022,Доска для рисования магнитная 30×40,Детские товары,Малышок,128990 +KIDS-0023,Мольберт детский двусторонний 105 см,Детские товары,Малышок,298999 KIDS-0024,Набор для лепки 12 цветов,Детские товары,Малышок,69000 KIDS-0025,Коврик игровой складной 180×150,Детские товары,Малышок,399000 KIDS-0026,Палатка игровая с окошками,Детские товары,Малышок,199000 KIDS-0027,Каталка с ручкой и звуком,Детские товары,Малышок,169000 -KIDS-0028,Самокат трёхколёсный до 50 кг,Детские товары,Малышок,299000 +KIDS-0028,Самокат трёхколёсный до 50 кг,Детские товары,Малышок,298999 KIDS-0029,Санки со спинкой и ремнём,Детские товары,Малышок,349000 -KIDS-0030,Рюкзак детский объёмом 8 л,Детские товары,Малышок,129000 -WEAR-0004,Футболка хлопковая размер 48,Одежда и обувь,Сатин,79000 -WEAR-0005,Майка хлопковая размер 46,Одежда и обувь,Сатин,59000 -WEAR-0006,Лонгслив трикотажный размер 50,Одежда и обувь,Сатин,109000 -WEAR-0007,Водолазка хлопковая размер 44,Одежда и обувь,Сатин,119000 -WEAR-0008,Толстовка с капюшоном размер 52,Одежда и обувь,Сатин,199000 +KIDS-0030,Рюкзак детский объёмом 8 л,Детские товары,Малышок,128990 +WEAR-0004,Футболка хлопковая размер 48,Одежда и обувь,Сатин,78990 +WEAR-0005,Майка хлопковая размер 46,Одежда и обувь,Сатин,58990 +WEAR-0006,Лонгслив трикотажный размер 50,Одежда и обувь,Сатин,108990 +WEAR-0007,Водолазка хлопковая размер 44,Одежда и обувь,Сатин,118990 +WEAR-0008,Толстовка с капюшоном размер 52,Одежда и обувь,Сатин,198999 WEAR-0009,Брюки спортивные размер 50,Одежда и обувь,Сатин,169000 -WEAR-0010,Легинсы хлопковые размер 46,Одежда и обувь,Сатин,99000 +WEAR-0010,Легинсы хлопковые размер 46,Одежда и обувь,Сатин,98990 WEAR-0011,Пижама трикотажная размер 48,Одежда и обувь,Сатин,179000 -WEAR-0012,Ночная сорочка хлопковая размер 50,Одежда и обувь,Сатин,129000 +WEAR-0012,Ночная сорочка хлопковая размер 50,Одежда и обувь,Сатин,128999 WEAR-0013,Фартук рабочий с тремя карманами,Одежда и обувь,Сатин,79000 WEAR-0014,Перчатки хозяйственные 3 пары,Одежда и обувь,Ходики,39000 -WEAR-0015,Варежки вязаные размер 8,Одежда и обувь,Ходики,69000 -WEAR-0016,Шапка вязаная с отворотом размер 56,Одежда и обувь,Ходики,89000 +WEAR-0015,Варежки вязаные размер 8,Одежда и обувь,Ходики,68990 +WEAR-0016,Шапка вязаная с отворотом размер 56,Одежда и обувь,Ходики,88990 WEAR-0017,Шарф шерстяной 30×180,Одежда и обувь,Ходики,119000 -WEAR-0018,Ремень текстильный длиной 120 см,Одежда и обувь,Ходики,79000 -WEAR-0019,Стельки войлочные 2 пары,Одежда и обувь,Ходики,39000 +WEAR-0018,Ремень текстильный длиной 120 см,Одежда и обувь,Ходики,78990 +WEAR-0019,Стельки войлочные 2 пары,Одежда и обувь,Ходики,38990 WEAR-0020,Шнурки круглые 120 см 3 пары,Одежда и обувь,Ходики,25000 WEAR-0021,Кеды текстильные размер 42,Одежда и обувь,Ходики,179000 -WEAR-0022,Кроссовки прогулочные размер 39,Одежда и обувь,Ходики,249000 -WEAR-0023,Ботинки утеплённые размер 41,Одежда и обувь,Ходики,349000 +WEAR-0022,Кроссовки прогулочные размер 39,Одежда и обувь,Ходики,248990 +WEAR-0023,Ботинки утеплённые размер 41,Одежда и обувь,Ходики,348990 WEAR-0024,Сапоги резиновые размер 40,Одежда и обувь,Ходики,199000 -WEAR-0025,Полуботинки кожаные размер 43,Одежда и обувь,Ходики,399000 +WEAR-0025,Полуботинки кожаные размер 43,Одежда и обувь,Ходики,398999 WEAR-0026,Сандалии с ремешками размер 38,Одежда и обувь,Ходики,189000 WEAR-0027,Сланцы резиновые размер 41,Одежда и обувь,Ходики,79000 WEAR-0028,Балетки текстильные размер 37,Одежда и обувь,Ходики,149000 WEAR-0029,Туфли домашние утеплённые размер 39,Одежда и обувь,Ходики,129000 -WEAR-0030,Галоши садовые размер 42,Одежда и обувь,Ходики,99000 +WEAR-0030,Галоши садовые размер 42,Одежда и обувь,Ходики,98990 diff --git a/docs/formats/clickstream-event.md b/docs/formats/clickstream-event.md index 7c2fee3..4a03031 100644 --- a/docs/formats/clickstream-event.md +++ b/docs/formats/clickstream-event.md @@ -5,8 +5,10 @@ пересобрать: `make docs`. Одно событие — одна строка: хит по образцу облачной выгрузки Яндекс Метрики. -Многозначное лежит в параллельных массивах одной длины, плюс одно сырое -JSON-поле `ecommerce`. Отдельной сущности «визит» в выгрузке нет — визиты +Многозначное лежит в параллельных массивах, плюс одно сырое JSON-поле +`ecommerce`. Длина у массивов общая **внутри группы**, а не по всему +событию: `purchase*` — по элементу на заказ (у нас всегда один), `product*` — +по элементу на товар. Отдельной сущности «визит» в выгрузке нет — визиты собирают на стороне хранилища, а `VisitID` дан как эталон для самопроверки. Имена и типы колонок — стороны источника. Хранилище принимает их как есть и @@ -21,6 +23,10 @@ JSON-поле `ecommerce`. Отдельной сущности «визит» в `add_to_cart` несёт один товар, `purchase` — состав заказа и блок `purchase*`. У остальных событий они пусты. +Деньги: `productPrice` — целые рубли, округление формата. Точная сумма +заказа живёт в `purchaseRevenue` и в сыром `ecommerce`, поэтому пересчитать +выручку по разобранным массивам нельзя — цены каталога бывают с копейками. + Всего колонок: 47. ## Идентификаторы и время diff --git a/docs/specs/2026-08-01-generator.md b/docs/specs/2026-08-01-generator.md index 158bd2d..49ae360 100644 --- a/docs/specs/2026-08-01-generator.md +++ b/docs/specs/2026-08-01-generator.md @@ -447,15 +447,18 @@ pytest-тест с маркером `perf` и таймаутом-обрубан - **D0 = 2026-06-01, понедельник** — решение владельца при нарезке (2026-08-01). Дата недавняя, чтобы данные первые месяцы выглядели свежими; привязки к реальному календарю у констант мира всё равно нет. -- **Измерено на собранном плане** (#38, канонический мир, 14 дней): - приток 3 828 кук в день в среднем, дневная аудитория 6 235–7 124 — - обе величины в вилках раздела 5. Накопленная аудитория за снимок — - 68 тыс. кук: 53,6 тыс. новыми плюс 14,5 тыс. возвратами предыстории - (при нарезке возвраты предыстории оценили вдвое скромнее — отсюда - ходившая раньше оценка ≈60 тыс.). Пар, у которых оба назначенных - заказа попали внутрь 14 дней, — 170; остальные пары горизонт +- **Измерено на собранном плане** (канонический мир, 14 дней; числа + перемерены при исполнении #40 — рычаг долгой жизни помеченного + покупателя сдвинул состав): приток 3 828 кук в день в среднем, дневная + аудитория 6 637–7 595 — обе величины в вилках раздела 5. Накопленная + аудитория за снимок — 70,2 тыс. кук: 53,6 тыс. новыми плюс 16,6 тыс. + возвратами предыстории (при нарезке возвраты предыстории оценили + скромнее — отсюда ходившая раньше оценка ≈60 тыс.). Пар, у которых оба + назначенных заказа попали внутрь 14 дней, — 192; остальные пары горизонт переживают, их вторая кука приходит позже. Цифры пересчитываются - прогоном плана, событий для них не нужно. + прогоном плана, событий для них не нужно. До правки #40 те же измерения + давали: аудитория 6 235–7 124, накопленная 68,1 тыс. (14,5 тыс. + возвратами предыстории), 170 пар. - **Конфигурация мира — модуль чистых данных** рядом с контрактом схемы: все числа мира в одном месте, написанном как приглашение любопытному менти крутить. Правка модуля — смена мира: чек манифеста честно @@ -538,10 +541,14 @@ pytest-тест с маркером `perf` и таймаутом-обрубан Правила лежат в докстринге `day.py`, и тест собирает визиты по ним заново, сверяя сборку с `VisitID`. - **Числа дня**: ≈1,4 визита на день активности куки и ≈4,8 страницы на - визит — 9,5 тыс. визитов и ~45 тыс. pageview в средний день; воронка - 8% визитов до корзины → 45% из них до оформления → 55% из них до - подтверждения, то есть конверсия визита ~2%. Итог сложится после - торговых событий (#40) и ляжет в манифест. + визит. Воронка при исполнении #39 была общей для всех: 8% визитов до + корзины → 45% из них до оформления → 55% из них до подтверждения, то есть + конверсия визита ~2% при 9,5 тыс. визитов и ~45 тыс. pageview в средний + день. При исполнении #40 воронка развелась надвое (блок ниже): обычному + посетителю 6% до корзины и те же 45% и 55%, помеченному покупателю — 18% + и 60%. Перемеренный средний день: 9 879 визитов, 47 433 pageview, 4,80 + страницы на визит, конверсия визита 2,4%; полные числа — в блоке #40, + оттуда же они лягут в манифест. - **Недельная волна применяется один раз.** Профиль ведёт приток, трафик наследует его через дневную аудиторию; измеренный размах трафика — ±7% против ±10% у притока. Второе умножение удвоило бы недельный размах. Если @@ -595,15 +602,24 @@ pytest-тест с маркером `perf` и таймаутом-обрубан воронке (#39); правятся оба. Отличается помеченный на обоих шагах воронки: и до корзины доходит чаще, и бросает её реже. В жизни различаются оба — кто пришёл смотреть, тот и кладёт реже, и до конца доводит реже; один шаг дал - бы половину картины. Числа под каждый шаг — калибровка. Границы правки — - вилки раздела 5: дневная - аудитория остаётся в пределах 6–8 тыс., средний день — около 50 тыс. - событий, конверсия визита — около 2%. Расти внутри вилки аудитории + бы половину картины. Числа под каждый шаг — калибровка: помеченному 18% до + корзины и 60% из корзины в оформление против 6% и 45% у обычного + посетителя. Обычного калибровка тоже задела — его доля до корзины опущена + с 8% до 6%, и это единственное число мира, изменённое вне двух рычагов. + Довод: помеченные живут дольше и занимают уже 12% дневной аудитории вместо + 5% людей когорты, поэтому при прежних 8% конверсия мира ушла бы к 2,9% — + за вилку. Рычаг метки не должен оплачиваться ростом конверсии, и границы + правки — вилки раздела 5: дневная аудитория остаётся в пределах + 6–8 тыс., средний день — около 50 тыс. событий, конверсия визита — + около 2%. Расти внутри вилки аудитории разрешено: помеченные ходят чаще, и это по-прежнему правдоподобный магазин. - Измеренные числа состава (блок #38 выше) после правки перемеряются и - переписываются; база до правки — в средний день аудитория 6 710, визитов - 9 249, событий 44 436, визитов с корзиной 780 (8,4%) и с покупкой 221 - (2,4%). Уточнение к разделу 1: вольные покупки по-прежнему решает день, но + Измерено после правки (канонический мир, 14 дней): в средний день + аудитория 7 166, визитов 9 879, событий 49 509, визитов с корзиной 765 + (7,7%) и с покупкой 240 (2,4%) — все три вилки выдержаны. База до правки + была: аудитория 6 710, визитов 9 249, событий 44 436, визитов с корзиной + 780 (8,4%) и с покупкой 221 (2,4%). Числа состава мира (блок #38 выше) + перемерены тем же прогоном. Уточнение к разделу 1: вольные покупки + по-прежнему решает день, но случайность в них больше не одинакова для всех. Отклонено: *оставить монетку* — «покупатель» остаётся именем без следа в данных; *один лифт без долгой жизни* — довод выше, повторных покупок он почти не прибавляет; @@ -662,6 +678,46 @@ pytest-тест с маркером `perf` и таймаутом-обрубан ломал бы его ровно там, ради чего написан. Визит, у которого страница подтверждения срезана полуночью (1,2% покупающих), покупки не даёт — заказа не было. +- **Счётчика покупок в плане состава не заводится.** Тикет просил «счётчики + покупок и двухкуковых пар сходятся с планом», но покупок план не считает и + считать не будет: раздел 1 обещает до генерации только состав — посетителей, + приток, пары, — а торговые счётчики складываются из поведения дня. Квота на + покупки заставила бы день добирать до точного числа, и конверсия в данных + вышла бы подозрительно ровной — условность видна там, куда лаба смотрит. + Сходятся пары; покупки проверяются постфактум по сгенерированному дню и по + вилке конверсии (~2% на визит). + +Решено при исполнении #40 самой реализацией: + +- **Торговый хвост разводит и соседние визиты, а не только сутки.** Правило + резки требует, чтобы визиты одной куки стояли дальше таймаута; считать это + расстояние от последней страницы, когда последним событием визита бывает + покупка, значило отдать таймауту торговый хвост — лаба сессий склеила бы + два визита в один и разошлась бы с `VisitID`. Расстояние считается от + последнего события; поймано тестом сборки визитов. +- **Полночь забирает подтверждение вместе с его покупкой.** Резерв суток + сначала считался только для визитов с обещанным планом заказом, и у + вольной покупки оставалось окно в несколько секунд: страница + подтверждения дожила до потока, а событие покупки за полночь вышло. Это + была бы потеря на клиентской стороне — класс этапов 4 и 6, заведённый по + недосмотру. Теперь запас под торговый хвост есть у всякой страницы + подтверждения: либо она уходит за границу суток вместе с покупкой, либо + остаются обе. Это ровно то правило, которое блок выше называет словами + «визит, у которого страница подтверждения срезана полуночью, покупки не + даёт»; в каноническом мире числа от правки не сдвинулись — случай не + выпал ни разу за 14 дней. +- **Задержка торгового события короче самой короткой паузы между + страницами.** Тогда событие корзины не обгоняет страницу, на которой + посетитель нажал кнопку, и не выходит за полночь: за ним в том же визите + всегда идёт страница, которая полночь пережила. +- **Номера торговых событий — из торгового подпотока.** `WatchID` строки + корзины и покупки рисует `COMMERCE`: возьми их у трафика, и правка + торгового поведения сдвинула бы трафиковые номера. Ряды двух подпотоков + проверяются на непересечение — обещание уникальности держит дедупликацию + при переигровке дня, и предполагать его нельзя. +- **Вариант товара — справочник мира.** Сырой `ecommerce` несёт `variant`, + которого в плоских колонках нет; строки живут в справочниках рядом с + устройствами и городами, а не сочиняются на месте. Остаётся открытым, за тикетами: diff --git a/generator/README.md b/generator/README.md index 89e5ad2..b2f73a8 100644 --- a/generator/README.md +++ b/generator/README.md @@ -5,7 +5,8 @@ спека [«Генератор (этап 2)»](../docs/specs/2026-08-01-generator.md). События уже есть: день-функция отдаёт по паре (зерно, D) упорядоченный поток -pageview. Торговые события и запуск снаружи — за следующими тикетами. +трёх видов — просмотр страницы, корзина, покупка. Клиентская сторона на этом +целая; заказы бэкенда и запуск снаружи — за следующими тикетами. ## Как это работает @@ -53,7 +54,12 @@ D0 живёт предыстория, поэтому любой день соб `data/catalog/products.csv`, общего у генератора и словаря ClickHouse. - `src/clickstream_generator/day.py` — день-функция: визиты, страницы, атрибуция, устройство и гео. Там же правила резки визитов и шов, на - который сядут торговые события. + который садятся торговые события. +- `src/clickstream_generator/commerce.py` — торговые события: что легло в + корзину, что из этого куплено, деньги, номера заказов и сырой `ecommerce`. + Своя случайность, поэтому правка торговли трафик не двигает. +- `src/clickstream_generator/ids.py` — номера событий: неповторяющиеся и + ниже 2^53. Обещание одно на обе половины дня, поэтому и живёт отдельно. - `src/clickstream_generator/schema.py` — контракт схемы: чистые данные о колонках выгрузки. Собственность генератора; из него выводятся сам генератор, его валидация и описание выгрузки в доках. diff --git a/generator/pyproject.toml b/generator/pyproject.toml index de4f2dd..f551fa2 100644 --- a/generator/pyproject.toml +++ b/generator/pyproject.toml @@ -6,7 +6,10 @@ readme = "README.md" # Верхняя граница — не педантизм: обещание побайтовой воспроизводимости # держится на зафиксированных версиях (спека генератора, раздел 2). requires-python = ">=3.14,<3.15" -dependencies = ["numpy>=2"] +dependencies = [ + "numpy>=2", + "orjson>=3.11.9", +] [dependency-groups] dev = [ diff --git a/generator/src/clickstream_generator/catalog.py b/generator/src/clickstream_generator/catalog.py index e3b25ac..f54d020 100644 --- a/generator/src/clickstream_generator/catalog.py +++ b/generator/src/clickstream_generator/catalog.py @@ -12,6 +12,10 @@ Что решено формой файла, а не его длиной: колонки `sku,name,category,brand, price`; артикул — четыре латинские буквы категории, дефис и четыре цифры; цена — целые копейки (деньги генератор считает целыми, спека, раздел 2). +Часть цен кратна рублю, часть несёт копейки — как в обычной рознице +(1 289,90 ₽). Без копеек урок про Float64 был бы беспредметным: округлять +нечего, и разрыв между `productPrice` и `purchaseRevenue` пришлось бы +выдумывать (спека генератора, раздел 9). Строк в файле может быть сколько угодно: ни генератор, ни тесты их не считают, а товар для карточки выбирается равномерно внутри категории. Популярность товаров не моделируется — придумывать вес каждой строке diff --git a/generator/src/clickstream_generator/commerce.py b/generator/src/clickstream_generator/commerce.py new file mode 100644 index 0000000..b773d90 --- /dev/null +++ b/generator/src/clickstream_generator/commerce.py @@ -0,0 +1,500 @@ +"""Торговые события: корзина и заказ на потоке дня. + +Здесь торговая половина мира. Событие корзины садится на карточку товара — +в жизни его шлёт кнопка на карточке, а не открытие страницы корзины; при +нескольких товарах в заказе иначе его и не разложить: событий столько, со +скольких карточек положили. Событие покупки садится на страницу +подтверждения. Оба встают на несколько секунд позже своей страницы, а +граница модельных суток режет всё, что за неё вышло: визит, у которого +подтверждение срезано полуночью, покупки не даёт — заказа не было. + +**Корзина шире заказа.** Посетитель кладёт товары тех карточек, которые +открывал в этом визите; карточка, открытая дважды, даёт одну позицию — +повторный просмотр это раздумье, а не второй товар. Покупает он не всё: +часть позиций остаётся брошенной. Иначе событие корзины не рассказывало бы +ничего сверх покупки — заказ был бы её точной копией, и сравнивать было бы +нечего. + +**Деньги считаются целыми копейками.** В колонку `productPrice` ложатся +целые рубли, как у Метрики, а дробное число в событии одно — +`purchaseRevenue`. Часть цен каталога несёт копейки, поэтому округление +видно: выручку по разобранным массивам не пересчитать, точная сумма живёт в +`purchaseRevenue` и в сыром `ecommerce`. Разрыв внутри одного события — это +настоящий урок формата, а не придуманный. + +**Промокод в событии есть, скидки в сумме нет.** Выручка, которую шлёт +клиент, — сумма позиций без скидки и доставки: код на сайте знает корзину, а +не итог расчёта. Скидку по коду насчитывает бэкенд (этап 3), и таблица «код +→ скидка» лежит в числах мира, чтобы обе стороны брали одну. + +**Номер заказа читаемый** — день модельного времени и порядковый номер +покупки в этом дне (`20260603-0042`). Он же `order_id` бэкенда: по нему +соединяется сверка (мастер-спека, раздел 4). Нумеруются все покупки, +дошедшие до потока дня, в порядке событий — до всяких потерь; поэтому номер +присваивается последним ходом, когда поток уже упорядочен. + +**Случайность — подпоток `COMMERCE`** (спека генератора, раздел 2): правка +торгового поведения не сдвигает трафиковый поток. Броски целые и векторные; +посточно собираются только строки — их numpy не умеет. +""" + +from dataclasses import dataclass +from typing import Any + +import numpy as np +import orjson +from numpy.typing import NDArray + +from clickstream_generator import catalog, ids, reference, world +from clickstream_generator.reference import Page +from clickstream_generator.seeds import Component, day_stream +from clickstream_generator.weights import pick + +# Таксономия: тип события и действие с товаром. Полный словарь торговых +# событий Метрики (detail, remove, impressions) стенд не берёт. +ADD_TO_CART = "add_to_cart" +PURCHASE = "purchase" +ADD_ACTION = "add" +PURCHASE_ACTION = "purchase" + +# Копеек в рубле: внутри генератора деньги целые, в колонках — рубли. +KOPECKS = 100 + +_QUANTITY_CUMULATIVE = np.cumsum(world.ITEM_QUANTITY_WEIGHTS) + +_PRODUCT_COLUMNS = ( + "productID", + "productName", + "productCategory", + "productPrice", + "productQuantity", + "productEventType", +) + + +@dataclass(frozen=True, slots=True) +class _Baskets: + """Корзины дня: позиции по корзинам и строка подтверждения заказа. + + Позиция — товар, положенный в корзину: `anchor` — строка карточки, на + которую сядет событие, `product` — номер товара в каталоге. Позиции + лежат подряд по корзинам, внутри корзины — по времени; `basket` говорит, + чья позиция, а `first` и `count` — где чей кусок. `confirmation` + отвечает на вопрос, дошла ли корзина до заказа: строка подтверждения + или −1 у брошенной. + """ + + anchor: NDArray[np.int64] + product: NDArray[np.int64] + basket: NDArray[np.int64] + first: NDArray[np.int64] + count: NDArray[np.int64] + confirmation: NDArray[np.int64] + + def __len__(self) -> int: + return self.confirmation.size + + def positions_of(self, basket: int, kept: NDArray[np.bool_]) -> NDArray[np.int64]: + """Позиции корзины, у которых стоит отметка: например, купленные.""" + here = slice(self.first[basket], self.first[basket] + self.count[basket]) + return self.first[basket] + np.flatnonzero(kept[here]) + + +@dataclass(frozen=True, slots=True) +class _Draws: + """Броски торгового подпотока: случайное решается один раз и разом. + + По позициям корзин — сколько штук берут, какой это вариант товара, + дошла ли позиция до заказа и на сколько секунд событие корзины отстало + от карточки. По корзинам — промокод и задержка события покупки. + """ + + quantity: NDArray[np.int64] + variant: NDArray[np.int64] + kept: NDArray[np.bool_] + cart_delay: NDArray[np.int64] + coupon: NDArray[np.int64] + order_delay: NDArray[np.int64] + + +@dataclass(frozen=True, slots=True) +class _Events: + """Строки одного вида торговых событий, готовые встать в поток. + + `raw` — сырой `ecommerce` каждой строки ещё объектом: номер заказа в нём + появится, когда поток будет упорядочен, а строка станет байтами один + раз, каноническим сериализатором. + """ + + columns: dict[str, NDArray[Any]] + page: NDArray[np.uint8] + product: NDArray[np.int64] + raw: list[dict[str, Any]] + + +def weave( + seed: int, + day: int, + columns: dict[str, NDArray[Any]], + page: NDArray[np.uint8], + product: NDArray[np.int64], +) -> tuple[dict[str, NDArray[Any]], NDArray[np.uint8], NDArray[np.int64]]: + """Вплетает торговые события в трафиковый поток и отдаёт поток целиком. + + Строки приходят упорядоченными по времени и такими же уходят: торговые + события встают между ними, и поток пересобирается одним порядком. + """ + rng = day_stream(seed, day, Component.COMMERCE) + baskets = _baskets(page, product, columns["VisitID"]) + if not len(baskets): + return columns, page, product + + goods = catalog.catalog() + draws = _draws(rng, baskets) + events = ( + _cart_events(columns, baskets, goods, draws), + _order_events(columns, baskets, goods, draws), + ) + return _stream(rng, columns, page, product, events) + + +def _draws(rng: np.random.Generator, baskets: _Baskets) -> _Draws: + """Всё случайное в торговых событиях — целыми числами и векторно.""" + positions = baskets.product.size + return _Draws( + quantity=1 + pick(rng, _QUANTITY_CUMULATIVE, positions), + variant=rng.integers(0, len(reference.PRODUCT_VARIANTS), positions), + kept=_kept(rng, baskets), + cart_delay=rng.integers(*world.TRADE_DELAY_SECONDS, positions), + coupon=_coupons(rng, len(baskets)), + order_delay=rng.integers(*world.TRADE_DELAY_SECONDS, len(baskets)), + ) + + +def _baskets( + page: NDArray[np.uint8], product: NDArray[np.int64], visit: NDArray[np.uint64] +) -> _Baskets: + """Что посетитель положил в корзину и дошёл ли до заказа. + + Корзина есть у визита, дошедшего до страницы корзины; в ней товары всех + карточек этого визита. Карточка, открытая дважды, даёт одну позицию — + остаётся первая: тогда товар и кладут. + """ + order = np.argsort(visit, kind="stable") + page, product, visit = page[order], product[order], visit[order] + + # Строки визита лежат подряд, внутри визита — по времени: поток пришёл + # упорядоченным, а сортировка по визиту устойчивая. + started = np.concatenate(([True], visit[1:] != visit[:-1])) + number = np.cumsum(started) - 1 + + with_cart = number[page == Page.CART] + basket_of_visit = np.full(int(started.sum()), -1, dtype=np.int64) + basket_of_visit[with_cart] = np.arange(with_cart.size) + + cards = np.flatnonzero((page == Page.PRODUCT) & (basket_of_visit[number] >= 0)) + # Ключ «визит и товар»: `np.unique` отдаёт индексы первых вхождений, + # поэтому вторая карточка того же товара позиции не добавляет. + key = number[cards] * catalog.catalog().sku.size + product[cards] + positions = np.sort(cards[np.unique(key, return_index=True)[1]]) + + # Подтверждение без корзины невозможно: полночь режет визит с хвоста, а + # корзина в нём раньше подтверждения — поэтому у каждого подтверждения + # корзина есть, и номер её всегда найдётся. + confirmed = np.flatnonzero(page == Page.CONFIRMATION) + confirmation = np.full(with_cart.size, -1, dtype=np.int64) + confirmation[basket_of_visit[number[confirmed]]] = order[confirmed] + + basket = basket_of_visit[number[positions]] + count = np.bincount(basket, minlength=with_cart.size) + return _Baskets( + anchor=order[positions], + product=product[positions], + basket=basket, + # Позиции лежат подряд, корзины идут по порядку визитов — поэтому + # начало каждого куска находится накопленной суммой. + first=np.cumsum(count) - count, + count=count, + confirmation=confirmation, + ) + + +def _kept(rng: np.random.Generator, baskets: _Baskets) -> NDArray[np.bool_]: + """Какие позиции корзины дошли до заказа: часть остаётся брошенной. + + Пустым заказ не бывает: если брошены все позиции, одна возвращается — + какая, решает свой бросок. Хотя бы одна позиция у корзины есть всегда: + перед корзиной визит обязательно открывал карточку. + """ + kept = ( + rng.integers(0, 100, baskets.product.size) >= world.ABANDONED_POSITION_PERCENT + ) + rescued = baskets.first + rng.integers(0, baskets.count) + empty = np.bincount(baskets.basket[kept], minlength=len(baskets)) == 0 + kept[rescued[empty]] = True + return kept + + +def _coupons(rng: np.random.Generator, baskets: int) -> NDArray[np.int64]: + """Промокод корзины: номер строки в таблице кодов или −1, если кода нет.""" + code = rng.integers(0, len(world.COUPONS), baskets) + return np.where(rng.integers(0, 100, baskets) < world.COUPON_PERCENT, code, -1) + + +def _cart_events( + columns: dict[str, NDArray[Any]], + baskets: _Baskets, + goods: catalog.Catalog, + draws: _Draws, +) -> _Events: + """Строки `add_to_cart`: по одной на каждый положенный товар.""" + positions = baskets.product.size + alone = [np.array([position]) for position in range(positions)] + + rows = _on_page(columns, baskets.anchor, ADD_TO_CART, draws.cart_delay) + rows["GoalsReached"] = _same( + np.array([world.GOAL_CART_ID], dtype=np.uint32), positions + ) + side, blocks = _product_side(goods, baskets, alone, draws, ADD_ACTION) + rows.update(side) + + return _Events( + columns=rows, + page=np.full(positions, Page.PRODUCT, dtype=np.uint8), + product=baskets.product.copy(), + raw=[{"currencyCode": world.CURRENCY, ADD_ACTION: block} for block in blocks], + ) + + +def _order_events( + columns: dict[str, NDArray[Any]], + baskets: _Baskets, + goods: catalog.Catalog, + draws: _Draws, +) -> _Events: + """Строки `purchase`: по одной на корзину, дошедшую до подтверждения.""" + ordered = np.flatnonzero(baskets.confirmation >= 0) + bought = [baskets.positions_of(basket, draws.kept) for basket in ordered] + + rows = _on_page( + columns, baskets.confirmation[ordered], PURCHASE, draws.order_delay[ordered] + ) + rows["GoalsReached"] = _same( + np.array([world.GOAL_PURCHASE_ID], dtype=np.uint32), ordered.size + ) + side, blocks = _product_side(goods, baskets, bought, draws, PURCHASE_ACTION) + 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() + ] + rows["purchaseRevenue"] = _cells( + [np.array([money / KOPECKS], dtype=np.float64) for money in kopecks] + ) + rows["purchaseCurrency"] = _same( + np.array([world.CURRENCY], dtype=object), ordered.size + ) + rows["purchaseCoupon"] = _cells([np.array([code], dtype=object) for code in codes]) + + raw = [ + { + "currencyCode": world.CURRENCY, + PURCHASE_ACTION: {"actionField": _action_field(money, code), **block}, + } + for money, code, block in zip(kopecks, codes, blocks, strict=True) + ] + return _Events( + columns=rows, + page=np.full(ordered.size, Page.CONFIRMATION, dtype=np.uint8), + product=np.full(ordered.size, -1, dtype=np.int64), + raw=raw, + ) + + +def _action_field(kopecks: int, code: str) -> dict[str, Any]: + """Блок `actionField` заказа: номер, выручка и купон, если он был. + + Номер пустой до сборки потока — его присваивает `_seal`, когда порядок + событий дня уже известен. + """ + field: dict[str, Any] = {"id": "", "revenue": kopecks / KOPECKS} + if code: + field["coupon"] = code + return field + + +def _product_side( + goods: catalog.Catalog, + baskets: _Baskets, + groups: list[NDArray[np.int64]], + draws: _Draws, + action: str, +) -> tuple[dict[str, NDArray[Any]], list[dict[str, Any]]]: + """Массивы `product*` и товарная часть сырого JSON — одним проходом. + + Массивы группы `product*` одной длины между собой: по элементу на товар. + С группой `purchase*` они не совпадают и не должны — там по элементу на + заказ (мастер-спека, раздел 1). + + Сырой JSON несёт больше, чем колонки: бренд, вариант товара и точную + цену с копейками. На этом и стоит лаба «сырое против разобранного» — + иначе в сыром лежало бы ровно то же самое. + """ + columns: dict[str, list[NDArray[Any]]] = {name: [] for name in _PRODUCT_COLUMNS} + blocks: list[dict[str, Any]] = [] + for group in groups: + numbers = baskets.product[group] + pieces = draws.quantity[group] + columns["productID"].append(np.array(goods.sku[numbers], dtype=object)) + columns["productName"].append(np.array(goods.name[numbers], dtype=object)) + columns["productCategory"].append( + np.array([_category(goods, number) for number in numbers], dtype=object) + ) + # Цена в колонке — целые рубли, как у Метрики: это округление и есть + # тот разрыв, из-за которого выручку по массивам не пересобрать. + columns["productPrice"].append((goods.price[numbers] + KOPECKS // 2) // KOPECKS) + columns["productQuantity"].append(pieces.astype(np.uint64)) + columns["productEventType"].append(np.full(group.size, action, dtype=object)) + blocks.append( + { + "products": [ + { + "id": goods.sku[number], + "name": goods.name[number], + "category": _category(goods, number), + "brand": goods.brand[number], + "variant": reference.PRODUCT_VARIANTS[draws.variant[position]], + "price": int(goods.price[number]) / KOPECKS, + "quantity": int(pieces[place]), + } + for place, (position, number) in enumerate( + zip(group.tolist(), numbers.tolist(), strict=True) + ) + ] + } + ) + return {name: _cells(values) for name, values in columns.items()}, blocks + + +def _category(goods: catalog.Catalog, number: int) -> str: + """Имя категории товара — то же, что в файле каталога и в словаре.""" + return catalog.CATEGORIES[goods.category[number]].name + + +def _on_page( + columns: dict[str, NDArray[Any]], + anchor: NDArray[np.int64], + event_type: str, + delay: NDArray[np.int64], +) -> dict[str, NDArray[Any]]: + """Событие на странице: её колонки целиком, свой тип и своё время. + + Страница у торгового события та же, что у просмотра, на который оно + село: тот же адрес и реферер, та же кука, тот же визит, устройство и + гео. Различаются тип, время и торговые колонки — их кладёт вызывающий. + """ + rows = {name: value[anchor] for name, value in columns.items()} + rows["EventType"] = np.full(anchor.size, event_type, dtype=object) + rows["UTCEventTime"] = columns["UTCEventTime"][anchor] + delay.astype( + "timedelta64[s]" + ) + return rows + + +def _stream( + rng: np.random.Generator, + columns: dict[str, NDArray[Any]], + page: NDArray[np.uint8], + product: NDArray[np.int64], + events: tuple[_Events, ...], +) -> tuple[dict[str, NDArray[Any]], NDArray[np.uint8], NDArray[np.int64]]: + """Собирает поток дня целиком: сутки режут хвост, время задаёт порядок.""" + traffic = page.size + trade = { + name: np.concatenate([part.columns[name] for part in events]) + for name in columns + } + raw = [block for part in events for block in part.raw] + + alive = trade["UTCEventTime"] < _midnight(columns) + np.timedelta64(1, "D") + trade = {name: value[alive] for name, value in trade.items()} + # Номера торговых строк — из торгового подпотока: возьми их день у + # трафика, и правка торгового поведения сдвинула бы трафиковые `WatchID`. + trade["WatchID"] = ids.unique_apart_from(rng, int(alive.sum()), columns["WatchID"]) + raw = [block for block, here in zip(raw, alive.tolist(), strict=True) if here] + + rows = { + name: np.concatenate((value, trade[name])) for name, value in columns.items() + } + page = np.concatenate((page, np.concatenate([part.page for part in events])[alive])) + product = np.concatenate( + (product, np.concatenate([part.product for part in events])[alive]) + ) + # Чей сырой блок в какой строке: у просмотра страницы блока нет. + place = np.full(traffic + len(raw), -1, dtype=np.int64) + place[traffic:] = np.arange(len(raw)) + + 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] + + +def _seal( + rows: dict[str, NDArray[Any]], + raw: list[dict[str, Any]], + place: NDArray[np.int64], + prefix: str, +) -> None: + """Раздаёт номера заказов и собирает сырой `ecommerce`. + + Номер получают все покупки, дошедшие до потока, в порядке событий — до + всяких потерь: этап 6 выбрасывает событие, когда номер уже присвоен, + иначе одна потеря перенумеровала бы чужие заказы и мост к бэкенду + разъехался бы. Строку собирает канонический сериализатор: руками это был + бы второй сериализатор со своим экранированием. + """ + number = 0 + for row in np.flatnonzero(place >= 0).tolist(): + block = raw[place[row]] + if rows["EventType"][row] == PURCHASE: + number += 1 + code = f"{prefix}-{number:04d}" + block[PURCHASE_ACTION]["actionField"]["id"] = code + rows["purchaseID"][row] = np.array([code], dtype=object) + rows["ecommerce"][row] = orjson.dumps(block).decode() + + +def _midnight(columns: dict[str, NDArray[Any]]) -> np.datetime64: + """Начало модельных суток абсолютной меткой: полночь в поясе счётчика.""" + date: np.datetime64 = columns["EventDate"][0] + return date.astype("datetime64[s]") - np.timedelta64( + world.COUNTER_TIMEZONE_MINUTES, "m" + ) + + +def _order_prefix(columns: dict[str, NDArray[Any]]) -> str: + """Первая половина номера заказа: день модельного времени, `20260603`.""" + return str(columns["EventDate"][0]).replace("-", "") + + +def _cells(values: list[NDArray[Any]]) -> NDArray[np.object_]: + """Колонка-массив: в каждой ячейке свой массив своего типа.""" + column = np.empty(len(values), dtype=object) + for row, value in enumerate(values): + column[row] = value + return column + + +def _same(value: NDArray[Any], size: int) -> NDArray[np.object_]: + """Колонка-массив, у которой во всех ячейках один и тот же массив.""" + value.flags.writeable = False + column = np.empty(size, dtype=object) + column.fill(value) + return column diff --git a/generator/src/clickstream_generator/day.py b/generator/src/clickstream_generator/day.py index 60d2ba5..384d3ba 100644 --- a/generator/src/clickstream_generator/day.py +++ b/generator/src/clickstream_generator/day.py @@ -1,9 +1,10 @@ """День-функция: (зерно, D) → упорядоченный поток событий модельных суток. Здесь трафиковая половина мира: визиты, страницы, атрибуция, устройство и -гео. Торговые события (#40) сядут на этот же поток и добавят к нему свои -строки; их колонки в pageview присутствуют, но пусты по смыслу — «пусто» -всегда пустой массив, пустая строка или 0, а не отсутствие ключа. +гео. Торговые события садятся на этот же поток и добавляют к нему свои +строки — их собирает `commerce` и им же поток заканчивается. У просмотра +страницы торговые колонки присутствуют, но пусты по смыслу: «пусто» — всегда +пустой массив, пустая строка или 0, а не отсутствие ключа. День — чистая функция зерна и номера дня: одна и та же пара даёт те же события, а день N+1 не трогает дни 1…N. Держится это на подпотоке @@ -25,23 +26,25 @@ 1. Визит принадлежит одной куке: склейка `ClientID` визитом не считается. 2. Пауза дольше 30 минут рвёт визит надвое, поэтому паузы внутри визита - всегда короче таймаута, а соседние визиты куки разведены дальше него. + всегда короче таймаута, а соседние визиты куки разведены дальше него — + считая от последнего события визита, которым бывает покупка, а не от + последней его страницы. 3. Граница модельных суток режет визит: события за полночь в дне не живут. Исключение одно — визит с заказом, обещанным планом двухкуковых пар: его - старт сдвигается назад, чтобы воронка уместилась в сутки. Это принятое - ограничение модели: обещание плана — гарантия, ради неё мы сужаем свободу - старта. Цена названа — около 24 визитов в день из ~9,5 тыс. не начинаются - в последние минуты суток. + старт сдвигается назад, чтобы воронка уместилась в сутки вместе с + торговым хвостом. Это принятое ограничение модели: обещание плана — + гарантия, ради неё мы сужаем свободу старта. Цена названа — около 24 + визитов в день из ~9,5 тыс. не начинаются в последние минуты суток. -**Шов для торговых событий (#40).** `Day` отдаёт, кроме колонок, два -выровненных по строкам ряда: `page` — какая это страница магазина, и -`product` — какой товар показывала карточка (−1 у прочих страниц). По ним -#40 узнаёт и то, куда вешать событие (корзина, оформление, подтверждение), -и то, что посетитель на самом деле смотрел: товар в корзине, которого никто -не открывал, — видимая глупость в воронке. Визит с назначенным заказом -всегда доходит до `/confirmation`, а перед корзиной у него всегда есть -карточка товара. Своей случайности #40 не занимает: подпоток `COMMERCE` -нетронут. +**Шов с торговыми событиями.** Поток несёт, кроме колонок, два выровненных +по строкам ряда: `page` — какая это страница магазина, и `product` — какой +товар показывала карточка (−1 у прочих страниц). По ним `commerce` знает и +то, куда сажать событие (карточка, подтверждение), и то, что посетитель на +самом деле смотрел: товар в корзине, которого никто не открывал, — видимая +глупость в воронке. Визит с назначенным заказом всегда доходит до +`/confirmation`, а перед корзиной у него всегда есть карточка товара. +Случайность у половин разная: трафик берёт подпоток `TRAFFIC`, торговля — +`COMMERCE`, и правка одной не сдвигает другую. """ from dataclasses import dataclass @@ -50,21 +53,24 @@ from typing import Any import numpy as np from numpy.typing import NDArray -from clickstream_generator import catalog, plan, reference, world +from clickstream_generator import catalog, commerce, ids, 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 DAY_SECONDS = 24 * 60 * 60 -# Потолок идентификаторов тот же, что у кук: выше 2^53 числа в JSON -# округляются (контракт схемы, `WatchID`). -ID_LIMIT = plan.CLIENT_ID_LIMIT - # Карточка перед корзиной обязательна: положить в корзину то, чего не # открывал, посетитель не может. MIN_PAGES_BEFORE_CART = 2 +# На столько секунд визит длиннее своих страниц: торговое событие встаёт +# позже страницы, на которую село, и последним событием визита бывает +# покупка, а не просмотр подтверждения. Величина нужна здесь дважды — визит +# с обещанным заказом обязан уместиться в сутки вместе с хвостом, а соседние +# визиты куки разводятся дальше таймаута тоже от хвоста, а не от страницы. +TRADE_TAIL_SECONDS = world.TRADE_DELAY_SECONDS[1] + _VISIT_COUNT_CUMULATIVE = np.cumsum(world.VISITS_PER_ACTIVE_DAY_WEIGHTS) _VISIT_PAGES_CUMULATIVE = np.cumsum(world.VISIT_PAGES_WEIGHTS) _SOURCE_CUMULATIVE = np.cumsum([source.weight for source in reference.TRAFFIC_SOURCES]) @@ -72,7 +78,7 @@ _SOURCE_CUMULATIVE = np.cumsum([source.weight for source in reference.TRAFFIC_SO @dataclass(frozen=True, slots=True) class Day: - """Поток событий одного дня: колонки выгрузки и шов для торговых событий. + """Поток событий одного дня: колонки выгрузки и страницы за ними. Строки упорядочены по времени — так их и проиграет проигрыватель. `columns` — колонки контракта схемы по его порядку, все до одной; @@ -124,9 +130,15 @@ def stream(seed: int, day: int) -> Day: second = np.repeat(start, visits.pages) + elapsed # Граница суток режет визит: хвост за полночью в этот день не попадает. - alive = second < DAY_SECONDS - visit_id = np.repeat(_unique_ids(rng, len(visits)), visits.pages) - watch_id = _unique_ids(rng, int(alive.sum())) + # Странице подтверждения нужно место и под её покупку: подтверждение без + # покупки было бы потерей на клиентской стороне, а она здесь честная — + # потери и дубли стенд заводит намеренно и позже (этапы 4 и 6). Поэтому + # полночь забирает подтверждение вместе с торговым хвостом или не + # забирает ни того, ни другого. + room = np.where(page == Page.CONFIRMATION, TRADE_TAIL_SECONDS, 0) + alive = second + room < DAY_SECONDS + visit_id = np.repeat(ids.unique(rng, len(visits)), visits.pages) + watch_id = ids.unique(rng, int(alive.sum())) rest = _columns(rng, day, audience, visits, page, product, second) columns = { @@ -136,12 +148,16 @@ def stream(seed: int, day: int) -> Day: } order = np.lexsort((columns["WatchID"], columns["UTCEventTime"])) - return Day( - day=day, - columns={name: value[order] for name, value in columns.items()}, - page=page[alive][order], - product=product[alive][order], + # Торговые события садятся на готовый трафиковый поток и отдают его + # целиком: в нём же они и упорядочиваются. + columns, page, product = commerce.weave( + seed, + day, + {name: value[order] for name, value in columns.items()}, + page[alive][order], + product[alive][order], ) + return Day(day=day, columns=columns, page=page, product=product) def _visits(rng: np.random.Generator, audience: plan.DayAudience) -> _Visits: @@ -161,7 +177,7 @@ def _visits(rng: np.random.Generator, audience: plan.DayAudience) -> _Visits: # Обещанный планом заказ достаётся первому визиту дня: слева от него # соседей нет, поэтому двигать его внутри суток можно свободно. ordering = audience.assigned_order[cookie] & (ordinal == 0) - stage = _funnel(rng, visits, ordering) + stage = _funnel(rng, audience.buyer[cookie], ordering) # Воронка удлиняет визит, а не съедает его: до корзины надо ещё дойти. browse = np.where( stage > 0, np.maximum(length - stage, MIN_PAGES_BEFORE_CART), length @@ -181,17 +197,32 @@ def _visits(rng: np.random.Generator, audience: plan.DayAudience) -> _Visits: def _funnel( - rng: np.random.Generator, visits: int, ordering: NDArray[np.bool_] + rng: np.random.Generator, + buyer: NDArray[np.bool_], + ordering: NDArray[np.bool_], ) -> NDArray[np.int64]: - """Докуда дошёл визит: 0 — до корзины не дошёл, 3 — до подтверждения.""" - draw = rng.integers(0, 100, (3, visits)) - cart = draw[0] < world.CART_PERCENT - checkout = cart & (draw[1] < world.CHECKOUT_OF_CART_PERCENT) + """Докуда дошёл визит: 0 — до корзины не дошёл, 3 — до подтверждения. + + Помеченный планом покупатель отличается на обоих шагах: и до корзины + доходит чаще, и бросает её реже. Склонность покупать — свойство + человека, а не визита, поэтому одинаковый для всех бросок оставил бы + метку плана словом без следа в данных (спека генератора, раздел 9). + """ + draw = rng.integers(0, 100, (3, buyer.size)) + cart = draw[0] < np.where(buyer, world.BUYER_CART_PERCENT, world.CART_PERCENT) + checkout = cart & ( + draw[1] + < np.where( + buyer, + world.BUYER_CHECKOUT_OF_CART_PERCENT, + world.CHECKOUT_OF_CART_PERCENT, + ) + ) confirmation = checkout & (draw[2] < world.CONFIRMATION_OF_CHECKOUT_PERCENT) stage = cart.astype(np.int64) + checkout + confirmation # Заказ, обещанный планом, воронку проходит целиком: гарантия пар стоит - # на том, что событие покупки в этот день случится (#40 его и повесит). + # на том, что событие покупки в этот день случится: его повесит `commerce`. stage[ordering] = len(reference.FUNNEL_PAGES) return stage @@ -219,7 +250,7 @@ def _walk( page[row] = table[step[row]] stage = int(visits.stage[visit]) if stage: - # В корзину — только с карточки: иначе #40 положит туда товар, + # В корзину — только с карточки: иначе в ней окажется товар, # которого посетитель не открывал. page[begin + browsed - 1] = Page.PRODUCT page[begin + browsed : begin + browsed + stage] = funnel[:stage] @@ -266,19 +297,28 @@ def _starts( """ hour = pick_row(rng, _hour_cumulative(day), audience.city[visits.cookie]) start = hour * 3600 + rng.integers(0, 3600, hour.size) - # Визит с обещанным заказом обязан уместиться в сутки целиком. - fits = np.minimum(start, DAY_SECONDS - duration - 1) + # Визит с обещанным заказом обязан уместиться в сутки целиком — вместе с + # торговым хвостом: событие покупки встаёт на секунды позже страницы + # подтверждения, и зажимать его к последней секунде значило бы ломать + # правило ровно там, ради чего оно написано. + fits = np.minimum(start, DAY_SECONDS - duration - TRADE_TAIL_SECONDS - 1) start = np.where(visits.ordering, fits, start) # Визиты куки идут по возрастанию времени и разведены дальше таймаута — # иначе лаба склеила бы два визита в один и разошлась бы с `VisitID`. + # Разводятся они от последнего события визита, а им бывает покупка: + # считать от последней страницы значило бы отдать таймауту торговый хвост. start = start[np.lexsort((start, visits.cookie))] for repeat in range(1, len(world.VISITS_PER_ACTIVE_DAY_WEIGHTS)): later = np.flatnonzero(visits.ordinal == repeat) earlier = later - 1 start[later] = np.maximum( start[later], - start[earlier] + duration[earlier] + world.VISIT_TIMEOUT_SECONDS + 1, + start[earlier] + + duration[earlier] + + TRADE_TAIL_SECONDS + + world.VISIT_TIMEOUT_SECONDS + + 1, ) return start @@ -370,7 +410,8 @@ def _columns( "RegionCity": by_city("name"), "RegionCountryID": np.full(total, reference.COUNTRY_REGION_ID, dtype=np.uint32), "RegionCityID": by_city("region_id", np.uint32), - # Цели дублируют торговые события, поэтому их ставит #40. + # Цели дублируют торговые события, поэтому их ставит `commerce`: + # у просмотра страницы достигнутых целей нет. "GoalsReached": _blank(total, "uint32"), # Своих параметров сайт стенда пока не шлёт: вариант A/B-теста был бы # постоянной куки, а не поведением дня. Решение отложено, не забыто: @@ -449,21 +490,6 @@ def _ip_addresses( ) -def _unique_ids(rng: np.random.Generator, size: int) -> NDArray[np.uint64]: - """Неповторяющиеся id ниже 2^53, разбросанные по диапазону. - - Уникальность обещана не для красоты: `WatchID` — ключ дедупликации при - переигровке дня (спека генератора, раздел 4), и два одинаковых id - склеили бы разные события. Поэтому не броски наугад, а шаги случайной - длины — они не повторяются по построению, — и потом перемешивание, чтобы - номер не выдавал порядок строк. Средний шаг — весь диапазон, делённый на - число событий, поэтому в среднем ряд занимает его половину. - """ - step = ID_LIMIT // (size + 1) - ids = np.cumsum(rng.integers(1, step + 1, size, dtype=np.uint64)) - return ids[np.argsort(rng.integers(0, size * size + 1, size), kind="stable")] - - def _hour_cumulative(day: int) -> NDArray[np.int64]: """Веса часов суток по городам, накопленные: строка города — его волна.""" # Суббота и воскресенье — последние два дня недели, а D0 — понедельник. diff --git a/generator/src/clickstream_generator/ids.py b/generator/src/clickstream_generator/ids.py new file mode 100644 index 0000000..80abc6a --- /dev/null +++ b/generator/src/clickstream_generator/ids.py @@ -0,0 +1,47 @@ +"""Идентификаторы событий: неповторяющиеся числа, которые переживут JSON. + +Уникальность обещана не для красоты: `WatchID` — ключ дедупликации при +переигровке дня (спека генератора, раздел 4), и два одинаковых номера +склеили бы разные события. Строк дня две породы — трафиковые и торговые, — +и рисует их разная случайность, поэтому обещание держится здесь, в одном +месте на весь генератор: у приёма одно определение, иначе дисциплина живёт +копиями и расходится с ними. +""" + +import numpy as np +from numpy.typing import NDArray + +# Потолок идентификаторов: выше 2^53 числа в JSON (jq, консоль браузера) +# округляются при разборе, и id перестаёт быть собой. Настоящая Метрика так +# не делает — её id длиннее (мастер-спека, раздел 1.1). +LIMIT = 2**53 + + +def unique(rng: np.random.Generator, size: int) -> NDArray[np.uint64]: + """Неповторяющиеся id ниже 2^53, разбросанные по диапазону. + + Не броски наугад, а шаги случайной длины — они не повторяются по + построению, — и потом перемешивание, чтобы номер не выдавал порядок + строк. Средний шаг — весь диапазон, делённый на число событий, поэтому в + среднем ряд занимает его половину. + """ + step = LIMIT // (size + 1) + numbers = np.cumsum(rng.integers(1, step + 1, size, dtype=np.uint64)) + return numbers[np.argsort(rng.integers(0, size * size + 1, size), kind="stable")] + + +def unique_apart_from( + rng: np.random.Generator, size: int, taken: NDArray[np.uint64] +) -> NDArray[np.uint64]: + """То же, но и с занятыми номерами ряд не пересекается. + + Торговые события берут случайность из своего подпотока — иначе правка + торгового поведения сдвинула бы трафик, — а значит, про уже розданные + трафиковые номера их ряд ничего не знает. Совпадение двух рядов на + диапазоне в 2^53 невероятно, но обещание уникальности держит дедуп, и + проверить его дешевле, чем предположить. + """ + while True: + numbers = unique(rng, size) + if not np.intersect1d(numbers, taken).size: + return numbers diff --git a/generator/src/clickstream_generator/plan.py b/generator/src/clickstream_generator/plan.py index 1e115c1..5b77d1b 100644 --- a/generator/src/clickstream_generator/plan.py +++ b/generator/src/clickstream_generator/plan.py @@ -9,7 +9,9 @@ Что план решает до генерации событий и чем связывает дни между собой: -- приток — кто и когда впервые появился, и сколько раз вернётся; +- приток — кто и когда впервые появился, и сколько раз вернётся; кука + помеченного покупателя живёт дольше прочих — это один из двух рычагов + метки, второй лежит в воронке дня; - двухкуковые пары — какой человек завёл вторую куку и в какие дни каждая из двух кук обязана оформить заказ; - паспорт куки — устройство и город: они у куки одни и те же во всех её @@ -32,17 +34,14 @@ from functools import lru_cache import numpy as np from numpy.typing import NDArray -from clickstream_generator import reference, world +from clickstream_generator import ids, reference, world from clickstream_generator.seeds import cohort_stream from clickstream_generator.weights import pick -# Куки живут числами ниже 2^53: выше JSON округляет — тот же довод, что у -# `WatchID` в контракте схемы. Граница не достигается: 2^53 сам уже за ней. -CLIENT_ID_LIMIT = 2**53 - # Кумулятивные веса: выбор по ним — целочисленный, бросок попадает в чью-то # долю общего веса. _RETURN_COUNT_CUMULATIVE = np.cumsum(world.RETURN_COUNT_WEIGHTS) +_BUYER_RETURN_COUNT_CUMULATIVE = np.cumsum(world.BUYER_RETURN_COUNT_WEIGHTS) _RETURN_DELAY_CUMULATIVE = np.cumsum(world.RETURN_DELAY_WEIGHTS) _CITY_CUMULATIVE = np.cumsum([city.weight for city in reference.CITIES]) _DEVICE_CUMULATIVE = np.cumsum( @@ -182,15 +181,19 @@ def cohort(seed: int, day: int) -> Cohort: ] cookies = people + paired.size - client_id = rng.integers(1, CLIENT_ID_LIMIT, cookies, dtype=np.uint64) + # Кука живёт числом ниже 2^53 — тот же потолок, что у номера события: + # выше JSON округляет при разборе. Граница не достигается. + client_id = rng.integers(1, ids.LIMIT, cookies, dtype=np.uint64) birth_day = np.full(cookies, day, dtype=np.int64) # Вторая кука рождается, пока человек ещё ходит: тем же затухающим # профилем, что и возвраты, — обычно через дни, изредка через месяцы. # Фиксированного зазора нет, иначе пары в данных узнавались бы по нему. birth_day[people:] += 1 + pick(rng, _RETURN_DELAY_CUMULATIVE, paired.size) + # Вторая кука принадлежит покупателю — как и первая кука его пары. + buyer_cookie = np.concatenate((buyer, np.ones(paired.size, dtype=bool))) active_cookie, active_day = _active_days( - rng, birth_day, day + world.RETURN_TAIL_DAYS + rng, birth_day, buyer_cookie, day + world.RETURN_TAIL_DAYS ) twins = np.column_stack((paired, np.arange(people, cookies, dtype=np.int64))) pair_cookies, pair_order_days = _assign_orders( @@ -204,8 +207,7 @@ def cohort(seed: int, day: int) -> Cohort: people=people, client_id=client_id, birth_day=birth_day, - # Вторая кука принадлежит покупателю — как и первая кука его пары. - buyer=np.concatenate((buyer, np.ones(paired.size, dtype=bool))), + buyer=buyer_cookie, active_cookie=active_cookie, active_day=active_day, pair_cookies=pair_cookies, @@ -271,13 +273,30 @@ def _influx(rng: np.random.Generator, day: int) -> int: def _active_days( - rng: np.random.Generator, birth_day: NDArray[np.int64], window_end: int + rng: np.random.Generator, + birth_day: NDArray[np.int64], + buyer: NDArray[np.bool_], + window_end: int, ) -> tuple[NDArray[np.int64], NDArray[np.int64]]: - """Дни активности каждой куки: день рождения и возвраты, пока окно открыто.""" + """Дни активности каждой куки: день рождения и возвраты, пока окно открыто. + + Помеченный планом покупатель живёт дольше прочих: одноразовым бывает + много реже и возвращается чаще. Это первый из двух рычагов метки + (второй — воронка дня): метки в событии нет, поэтому «постоянный + покупатель» читается в данных только как кука, которая ходит неделями и + покупает не раз. Одним лифтом конверсии этого не добиться — кука живёт + меньше двух визитов за снимок, и второй покупке негде случиться (спека + генератора, раздел 9). + """ cookies = birth_day.size returns = np.zeros(cookies, dtype=np.int64) - returning = rng.integers(0, 100, cookies) >= world.ONE_SHOT_PERCENT - returns[returning] = 1 + pick(rng, _RETURN_COUNT_CUMULATIVE, int(returning.sum())) + one_shot = np.where(buyer, world.BUYER_ONE_SHOT_PERCENT, world.ONE_SHOT_PERCENT) + returning = rng.integers(0, 100, cookies) >= one_shot + for here, weights in ( + (returning & ~buyer, _RETURN_COUNT_CUMULATIVE), + (returning & buyer, _BUYER_RETURN_COUNT_CUMULATIVE), + ): + returns[here] = 1 + pick(rng, weights, int(here.sum())) owner = np.repeat(np.arange(cookies, dtype=np.int64), returns) delay = 1 + pick(rng, _RETURN_DELAY_CUMULATIVE, owner.size) diff --git a/generator/src/clickstream_generator/reference.py b/generator/src/clickstream_generator/reference.py index a7ac578..1a2ca47 100644 --- a/generator/src/clickstream_generator/reference.py +++ b/generator/src/clickstream_generator/reference.py @@ -77,6 +77,19 @@ SEARCH_QUERIES = tuple( ) +# Вариант товара — цвет или исполнение карточки. Живёт только в сыром +# `ecommerce`: в плоских массивах события такой колонки нет, и это одна из +# причин, по которым лаба «сырое против разобранного» вообще имеет смысл. +PRODUCT_VARIANTS = ( + "стандарт", + "белый", + "серый", + "бежевый", + "синий", + "зелёный", +) + + # Коды типа устройства у Метрики: 1 — десктоп, 2 — телефон, 3 — планшет, # 4 — телевизор. Телефон назван отдельно: по нему различаются и мобильный # адрес, и вторая кука пары — «телефон и ноутбук» (мастер-спека, раздел 5). diff --git a/generator/src/clickstream_generator/schema_doc.py b/generator/src/clickstream_generator/schema_doc.py index 888f051..277dff1 100644 --- a/generator/src/clickstream_generator/schema_doc.py +++ b/generator/src/clickstream_generator/schema_doc.py @@ -21,8 +21,10 @@ PREAMBLE = """# Описание выгрузки: событие кликстр пересобрать: `make docs`. Одно событие — одна строка: хит по образцу облачной выгрузки Яндекс Метрики. -Многозначное лежит в параллельных массивах одной длины, плюс одно сырое -JSON-поле `ecommerce`. Отдельной сущности «визит» в выгрузке нет — визиты +Многозначное лежит в параллельных массивах, плюс одно сырое JSON-поле +`ecommerce`. Длина у массивов общая **внутри группы**, а не по всему +событию: `purchase*` — по элементу на заказ (у нас всегда один), `product*` — +по элементу на товар. Отдельной сущности «визит» в выгрузке нет — визиты собирают на стороне хранилища, а `VisitID` дан как эталон для самопроверки. Имена и типы колонок — стороны источника. Хранилище принимает их как есть и @@ -37,6 +39,10 @@ JSON-поле `ecommerce`. Отдельной сущности «визит» в `add_to_cart` несёт один товар, `purchase` — состав заказа и блок `purchase*`. У остальных событий они пусты. +Деньги: `productPrice` — целые рубли, округление формата. Точная сумма +заказа живёт в `purchaseRevenue` и в сыром `ecommerce`, поэтому пересчитать +выручку по разобранным массивам нельзя — цены каталога бывают с копейками. + Всего колонок: {count}.""" TABLE_HEADER = ( diff --git a/generator/src/clickstream_generator/world.py b/generator/src/clickstream_generator/world.py index 9740b59..5b0e995 100644 --- a/generator/src/clickstream_generator/world.py +++ b/generator/src/clickstream_generator/world.py @@ -82,6 +82,15 @@ BUYER_PERCENT = 5 # этом стоит лаба про склейку личности. PAIRED_BUYER_PERCENT = 15 +# Первый рычаг метки покупателя: помеченный дольше живёт и чаще возвращается. +# Одноразовым он бывает много реже прочих, а возвращается вдвое чаще — +# отсюда кука, которая ходит неделями. Без этого рычага «постоянный +# покупатель» в данных не читается: метки в событии нет и не будет +# (кликстрим анонимен), а кука живёт меньше двух визитов за снимок — второй +# покупке негде случиться (спека генератора, раздел 9). +BUYER_ONE_SHOT_PERCENT = 35 +BUYER_RETURN_COUNT_WEIGHTS = (10, 11, 12, 12, 11, 10, 9, 7, 5, 4, 3, 2) + # --- Числа дня: суточная волна, визиты, воронка --------------------------- # Суточная волна буднего дня: проценты от среднего часа, от 00 до 23 часов @@ -111,7 +120,7 @@ VISITS_PER_ACTIVE_DAY_WEIGHTS = (70, 22, 8) # Длина визита в страницах: веса для 1, 2, 3 … страниц. Первая доля — отказы # (посмотрел одну страницу и ушёл), дальше затухающий хвост. В среднем ≈4,8 # страницы: вместе с числом визитов это ~45 тыс. pageview в средний день, -# и до ~50 тыс. добирают торговые события (#40). +# и до ~50 тыс. добирают торговые события. VISIT_PAGES_WEIGHTS = (250, 150, 120, 100, 88, 78, 68, 58, 50, 42, 35, 28, 22, 16) # Таймаут визита: пауза дольше этой рвёт визит надвое. Правило резки, по @@ -128,9 +137,59 @@ LONG_PAUSE_SECONDS = (300, 1500) LONG_PAUSE_PERCENT = 12 # Воронка: доля визитов, дошедших до корзины, и доли следующих шагов от -# предыдущего. Произведение — конверсия визита в оформленный заказ: 2% +# предыдущего. Произведение — конверсия визита в оформленный заказ # (спека генератора, раздел 9). Гарантированные планом заказы двухкуковых # пар проходят воронку целиком независимо от этих долей. -CART_PERCENT = 8 +CART_PERCENT = 6 CHECKOUT_OF_CART_PERCENT = 45 CONFIRMATION_OF_CHECKOUT_PERCENT = 55 + +# Второй рычаг метки покупателя: помеченный отличается на обоих шагах +# воронки — и до корзины доходит чаще, и бросает её реже. В жизни +# различаются оба: кто пришёл смотреть, тот и кладёт реже, и до конца +# доводит реже; один шаг дал бы половину картины. Шаг подтверждения общий: +# оплата — про магазин, а не про склонность покупать. +BUYER_CART_PERCENT = 18 +BUYER_CHECKOUT_OF_CART_PERCENT = 60 + +# --- Числа торговых событий: корзина, заказ, деньги ------------------------ + +# Торговое событие встаёт на несколько секунд позже своей страницы: корзина +# позже карточки, покупка позже подтверждения. Задержка короче самой +# короткой паузы между страницами — иначе событие корзины обогнало бы +# страницу, на которой посетитель его нажал. +TRADE_DELAY_SECONDS = (2, 8) + +# Доля позиций корзины, которые остались брошенными: заказ уже корзины. +# Иначе событие корзины не рассказывало бы ничего сверх покупки — заказ был +# бы её точной копией, и сравнивать было бы нечего. Пустым заказ не бывает: +# если брошены все позиции, одна остаётся (спека генератора, раздел 9). +ABANDONED_POSITION_PERCENT = 15 + +# Сколько штук одного товара берут: веса для 1, 2, 3 штук. Обычно одна, +# изредка две-три — непродовольственная розница. +ITEM_QUANTITY_WEIGHTS = (85, 11, 4) + +# Валюта магазина: один регион присутствия — одна валюта. +CURRENCY = "RUB" + +# Доля заказов с промокодом. +COUPON_PERCENT = 20 + +# Промокоды: код и скидка в процентах. Таблица — число мира, а не выдумка +# бэкенда: этап 3 берёт её готовой и обязан дать заказу с кодом скидку, +# иначе данные соврут. В клиентскую выручку скидка не входит — код на сайте +# знает корзину, а не итог расчёта (спека генератора, раздел 9). Цифры в +# коде — те же проценты: скидка в рублях потребовала бы второго правила +# чтения таблицы, а вместе с ним и второго вида скидки на стороне бэкенда. +COUPONS = ( + ("VESNA10", 10), + ("DOMASHNIY5", 5), + ("PERVYY15", 15), + ("UYUT7", 7), +) + +# Цели счётчика: корзина и покупка. Цели дублируют торговые события — в бою +# так и бывает (мастер-спека, раздел 1.2). +GOAL_CART_ID = 42150001 +GOAL_PURCHASE_ID = 42150002 diff --git a/generator/tests/test_catalog.py b/generator/tests/test_catalog.py index 2de568b..fb20d9e 100644 --- a/generator/tests/test_catalog.py +++ b/generator/tests/test_catalog.py @@ -54,6 +54,18 @@ def test_every_row_is_filled_and_priced_in_whole_kopecks(): assert low <= int(row["price"]) <= high, row["sku"] +def test_part_of_the_prices_carry_kopecks(): + """Без копеек урок про Float64 беспредметен: округлять было бы нечего. + + Сторожится не доля, а то, на чём стоит урок: цены бывают и кратные + рублю, и с копейками — тогда `productPrice` округляется форматом, а + `purchaseRevenue` несёт точную сумму. + """ + kopecks = [int(row["price"]) % 100 for row in rows()] + assert any(rest for rest in kopecks) + assert any(not rest for rest in kopecks) + + def test_every_category_of_the_assortment_is_covered(): """Каталог покрывает ассортимент целиком: пустых категорий не бывает.""" present = {row["category"] for row in rows()} diff --git a/generator/tests/test_commerce.py b/generator/tests/test_commerce.py new file mode 100644 index 0000000..a135f12 --- /dev/null +++ b/generator/tests/test_commerce.py @@ -0,0 +1,488 @@ +"""Торговые события: корзина шире заказа, деньги целые, номера читаемые. + +Числа мира тесты сторожат вилками спеки, а не точными значениями: менти +крутит конфигурацию, и падать тесты должны там, где сдвинулся вывод («заказ +уже корзины», «конверсия визита около 2%»), а не при каждой правке. + +Неделя дня-функции стоит секунд, поэтому дни, которые нужны нескольким +тестам, считаются один раз на модуль. +""" + +import json +from typing import Any + +import numpy as np +import pytest +from numpy.typing import NDArray + +from clickstream_generator import catalog, commerce, day, plan, schema, world +from clickstream_generator.reference import Page +from clickstream_generator.seeds import CANONICAL_SEED, Component + +WEEKDAY = 2 +WEEK = 7 + + +@pytest.fixture(autouse=True) +def fresh_memo(): + """Когорты запоминаются; тесты сравнивают вычисления, а не ссылки.""" + plan.cohort.cache_clear() + + +@pytest.fixture(scope="module") +def weekday() -> day.Day: + return day.stream(CANONICAL_SEED, WEEKDAY) + + +@pytest.fixture(scope="module") +def week() -> list[day.Day]: + """Неделя мира: повторные покупки и обещания пар живут между днями.""" + return [day.stream(CANONICAL_SEED, number) for number in range(WEEK)] + + +def rows_of(events: day.Day, kind: str) -> dict[str, NDArray[Any]]: + """Строки одного типа события — все колонки разом.""" + here = events.columns["EventType"] == kind + return {name: value[here] for name, value in events.columns.items()} + + +def cells(column: NDArray[Any]) -> list[list[Any]]: + return [cell.tolist() for cell in column] + + +def raw_of(rows: dict[str, NDArray[Any]]) -> list[dict[str, Any]]: + """Сырой `ecommerce` разобранный — чужим разбором, не своим сборщиком.""" + return [json.loads(text) for text in rows["ecommerce"]] + + +def by_visit(events: day.Day) -> NDArray[np.int64]: + """Порядок строк «визит, потом время» — так их читает лаба сессий.""" + return np.lexsort((events.columns["UTCEventTime"], events.columns["VisitID"])) + + +def test_the_day_carries_both_trade_events(weekday: day.Day): + """Таксономия ожила: в дне есть и корзина, и покупка.""" + carts = rows_of(weekday, commerce.ADD_TO_CART) + orders = rows_of(weekday, commerce.PURCHASE) + assert orders["WatchID"].size > 100 + # Корзина шире заказа и по событиям: не всякая корзина доходит до кассы. + assert carts["WatchID"].size > 2 * orders["WatchID"].size + + +def confirmed_visits(events: day.Day) -> set[int]: + """Визиты, чья страница подтверждения дожила до потока.""" + pageview = events.columns["EventType"] == "pageview" + here = pageview & (events.page == Page.CONFIRMATION) + return set(events.columns["VisitID"][here].tolist()) + + +def test_a_confirmation_in_the_stream_always_has_its_purchase(weekday: day.Day): + """Клиентская сторона честная: подтверждение без покупки — это потеря. + + Потери и дубли стенд заводит намеренно и позже (этапы 4 и 6); здесь их + быть не должно ни одной. Обратное тоже верно: покупка без подтверждения + в потоке — заказ ниоткуда. + """ + bought = set(rows_of(weekday, commerce.PURCHASE)["VisitID"].tolist()) + assert confirmed_visits(weekday) == bought + + +def test_the_day_boundary_takes_the_confirmation_together_with_its_purchase( + monkeypatch: pytest.MonkeyPatch, +): + """Полночь режет подтверждение и покупку вместе — или не режет ни одну. + + Иначе визит отдал бы страницу «Заказ оформлен» без события покупки, и на + клиентской стороне завелась бы потеря. Хвост торгового события здесь + растянут до часа: в каноническом мире он секунды, и такой визит + выпадает раз в годы, а правило обязано держаться при любом хвосте. + """ + monkeypatch.setattr(world, "TRADE_DELAY_SECONDS", (3_000, 3_600)) + monkeypatch.setattr(day, "TRADE_TAIL_SECONDS", 3_600) + events = day.stream(CANONICAL_SEED, WEEKDAY) + bought = set(rows_of(events, commerce.PURCHASE)["VisitID"].tolist()) + assert bought + assert confirmed_visits(events) == bought + + +def test_a_purchase_carries_exactly_one_order_number(weekday: day.Day): + """Одно подтверждение — один заказ (мастер-спека, раздел 4).""" + orders = rows_of(weekday, commerce.PURCHASE) + for name in ("purchaseID", "purchaseRevenue", "purchaseCurrency", "purchaseCoupon"): + assert {cell.size for cell in orders[name]} == {1}, name + assert {cell[0] for cell in orders["purchaseCurrency"]} == {world.CURRENCY} + + +def test_the_cart_event_has_no_order_block(weekday: day.Day): + """У корзины заказа ещё нет: блок `purchase*` пуст, как у просмотра.""" + carts = rows_of(weekday, commerce.ADD_TO_CART) + for name in ("purchaseID", "purchaseRevenue", "purchaseCurrency", "purchaseCoupon"): + assert {cell.size for cell in carts[name]} == {0}, name + + +def test_the_product_arrays_of_an_event_share_one_length(weekday: day.Day): + """Длина общая внутри группы `product*`; с `purchase*` она не совпадает.""" + trade = weekday.columns["EventType"] != "pageview" + names = [ + column.name for column in schema.COLUMNS if column.name.startswith("product") + ] + assert len(names) == 6 + sizes = np.array( + [[cell.size for cell in weekday.columns[name][trade]] for name in names] + ) + assert np.all(sizes == sizes[0]) + # Корзина — всегда один товар, заказ — от одного и больше. + carts = rows_of(weekday, commerce.ADD_TO_CART) + assert {cell.size for cell in carts["productID"]} == {1} + orders = rows_of(weekday, commerce.PURCHASE) + assert min(cell.size for cell in orders["productID"]) == 1 + assert max(cell.size for cell in orders["productID"]) > 1 + + +def test_the_raw_json_agrees_with_the_flat_arrays(weekday: day.Day): + """Сырое и разобранное — одно событие, поле в поле. + + Сверяется всё, что есть по обе стороны: разъедься хоть одно поле, лаба + «сырое против разобранного» учила бы неправде. Цена — единственная пара, + где расхождение законно: в колонке целые рубли, в сыром точная сумма, + поэтому сверяется не равенство, а что колонка — та же цена, округлённая + до рубля. + """ + for kind, action in ( + (commerce.ADD_TO_CART, commerce.ADD_ACTION), + (commerce.PURCHASE, commerce.PURCHASE_ACTION), + ): + rows = rows_of(weekday, kind) + assert rows["ecommerce"].size > 100 + for number, block in enumerate(raw_of(rows)): + assert block["currencyCode"] == world.CURRENCY + products = block[action]["products"] + for field, column in ( + ("id", "productID"), + ("name", "productName"), + ("category", "productCategory"), + ("quantity", "productQuantity"), + ): + assert [item[field] for item in products] == ( + rows[column][number].tolist() + ), (kind, field) + assert rows["productEventType"][number].tolist() == [action] * len(products) + prices = zip( + (item["price"] for item in products), + rows["productPrice"][number].tolist(), + strict=True, + ) + assert all(abs(exact - whole) <= 0.5 for exact, whole in prices) + + orders = rows_of(weekday, commerce.PURCHASE) + for number, block in enumerate(raw_of(orders)): + field = block[commerce.PURCHASE_ACTION]["actionField"] + assert field["id"] == orders["purchaseID"][number][0] + assert field["revenue"] == orders["purchaseRevenue"][number][0] + + +def test_the_raw_json_carries_what_the_arrays_do_not(weekday: day.Day): + """Иначе лаба «сырое против разобранного» разбирала бы то же самое.""" + orders = rows_of(weekday, commerce.PURCHASE) + goods = catalog.catalog() + brands = set(goods.brand.tolist()) + seen_brands: set[str] = set() + for block in raw_of(orders): + for item in block[commerce.PURCHASE_ACTION]["products"]: + assert item["brand"] in brands + assert item["variant"] + seen_brands.add(item["brand"]) + assert len(seen_brands) > 1 + assert not any( + column.name in ("productBrand", "productVariant") for column in schema.COLUMNS + ) + + +def test_money_is_whole_kopecks_inside_and_float_only_on_the_surface(weekday: day.Day): + """Деньги целые; дробное число в событии одно — выручка (спека, раздел 2).""" + floats = [ + column.name for column in schema.COLUMNS if column.numpy_dtype == "float64" + ] + assert floats == ["purchaseRevenue"] + + goods = catalog.catalog() + orders = rows_of(weekday, commerce.PURCHASE) + sku = {article: number for number, article in enumerate(goods.sku.tolist())} + for number, revenue in enumerate(orders["purchaseRevenue"]): + assert revenue.dtype == np.float64 + kopecks = sum( + int(goods.price[sku[article]]) * pieces + for article, pieces in zip( + orders["productID"][number].tolist(), + orders["productQuantity"][number].tolist(), + strict=True, + ) + ) + assert revenue[0] == kopecks / 100 + assert orders["productPrice"][number].dtype == np.int64 + + +def test_the_rounded_price_does_not_add_up_to_the_revenue(weekday: day.Day): + """Часть цен несёт копейки, и выручку по массивам события не пересобрать. + + Разрыв живёт внутри одного события: `productPrice` округлён форматом, + `purchaseRevenue` точна. Это и есть урок про Float64 — не выдуманный. + """ + orders = rows_of(weekday, commerce.PURCHASE) + apart = 0 + for number, revenue in enumerate(orders["purchaseRevenue"]): + by_arrays = float( + (orders["productPrice"][number] * orders["productQuantity"][number]).sum() + ) + apart += by_arrays != revenue[0] + assert 0.1 < apart / orders["WatchID"].size < 0.9 + + +def test_the_order_is_narrower_than_the_cart(weekday: day.Day): + """Часть положенных позиций не куплена — иначе сравнивать было бы нечего.""" + carts = rows_of(weekday, commerce.ADD_TO_CART) + orders = rows_of(weekday, commerce.PURCHASE) + put: dict[int, set[str]] = {} + for visit, article in zip( + carts["VisitID"].tolist(), (cell[0] for cell in carts["productID"]), strict=True + ): + put.setdefault(visit, set()).add(article) + + dropped = whole = 0 + for visit, bought in zip( + orders["VisitID"].tolist(), cells(orders["productID"]), strict=True + ): + assert bought, "пустых заказов не бывает" + assert set(bought) <= put[visit], "куплено то, чего не клали в корзину" + assert len(set(bought)) == len(bought), "позиция в заказе одна на товар" + dropped += len(put[visit]) - len(bought) + whole += len(put[visit]) + assert 0.05 < dropped / whole < 0.30 + + +def test_the_cart_holds_only_what_the_visitor_opened(weekday: day.Day): + """Товар в корзине, которого никто не открывал, — глупость в воронке.""" + order = by_visit(weekday) + visit = weekday.columns["VisitID"][order] + kind = weekday.columns["EventType"][order] + product = weekday.product[order] + page = weekday.page[order] + + shown: dict[int, set[int]] = {} + for number, this in enumerate(visit.tolist()): + if kind[number] == "pageview" and page[number] == Page.PRODUCT: + shown.setdefault(this, set()).add(int(product[number])) + + put: dict[int, list[int]] = {} + for number, this in enumerate(visit.tolist()): + if kind[number] == commerce.ADD_TO_CART: + put.setdefault(this, []).append(int(product[number])) + assert put + for this, products in put.items(): + assert set(products) <= shown[this] + # Карточка, открытая дважды, даёт одну позицию, а не две. + assert len(set(products)) == len(products) + + +def test_a_trade_event_sits_on_the_page_that_sent_it(weekday: day.Day): + """Корзина — на карточке того самого товара, покупка — на подтверждении. + + Событие не просто стоит после какой-то страницы: у события корзины + предыдущая строка визита — карточка именно этого товара, и товар в + массивах события тот же. Иначе в корзину попал бы товар, которого + посетитель не открывал, а событие село бы на чужую страницу. + """ + order = by_visit(weekday) + columns = {name: value[order] for name, value in weekday.columns.items()} + page, product = weekday.page[order], weekday.product[order] + kind = columns["EventType"] + goods = catalog.catalog() + trade = np.flatnonzero(kind != "pageview") + assert trade.size > 100 + + for row in trade.tolist(): + assert columns["VisitID"][row] == columns["VisitID"][row - 1] + assert columns["URL"][row] == columns["URL"][row - 1] + assert columns["Referer"][row] == columns["Referer"][row - 1] + away = (columns["UTCEventTime"][row] - columns["UTCEventTime"][row - 1]).astype( + "int64" + ) + assert 0 < away < world.TRADE_DELAY_SECONDS[1] + + if kind[row] == commerce.ADD_TO_CART: + assert page[row] == page[row - 1] == Page.PRODUCT + # Карточка предыдущей строки — карточка этого самого товара. + assert product[row] == product[row - 1] >= 0 + assert columns["productID"][row].tolist() == [goods.sku[product[row]]] + # У страницы входа в адресе ещё метки перехода — путь до «?». + address = columns["URL"][row].split("?")[0] + assert address.endswith(f"/product/{goods.sku[product[row]]}") + else: + assert page[row] == page[row - 1] == Page.CONFIRMATION + assert kind[row - 1] == "pageview" + + +def test_the_goals_repeat_the_trade_events(weekday: day.Day): + """Цели дублируют события — в бою так и бывает (мастер-спека, 1.2).""" + goals = { + kind: {tuple(cell.tolist()) for cell in rows_of(weekday, kind)["GoalsReached"]} + for kind in ("pageview", commerce.ADD_TO_CART, commerce.PURCHASE) + } + assert goals["pageview"] == {()} + assert goals[commerce.ADD_TO_CART] == {(world.GOAL_CART_ID,)} + assert goals[commerce.PURCHASE] == {(world.GOAL_PURCHASE_ID,)} + + +def test_the_order_number_reads_as_the_day_and_the_count(weekday: day.Day): + """Номер — день и порядковый номер покупки в нём; он же `order_id` бэкенда.""" + orders = rows_of(weekday, commerce.PURCHASE) + numbers = [cell[0] for cell in orders["purchaseID"]] + date = str(weekday.columns["EventDate"][0]).replace("-", "") + assert numbers == [f"{date}-{place:04d}" for place in range(1, len(numbers) + 1)] + assert len(set(numbers)) == len(numbers) + # Нумерация идёт в порядке событий: строки дня уже упорядочены по времени. + assert list(orders["UTCEventTime"]) == sorted(orders["UTCEventTime"]) + + +def test_the_coupon_comes_from_the_world_table_and_does_not_touch_the_revenue( + weekday: day.Day, +): + """Промокод в событии есть, скидки в сумме нет: клиент шлёт сумму позиций.""" + orders = rows_of(weekday, commerce.PURCHASE) + known = {code for code, _ in world.COUPONS} + coupons = [cell[0] for cell in orders["purchaseCoupon"]] + assert set(coupons) <= known | {""} + with_coupon = [code for code in coupons if code] + assert 0.05 < len(with_coupon) / len(coupons) < 0.40 + assert len(set(with_coupon)) > 1 + + # Скидка в клиентскую выручку не входит: сумма сходится с позициями и с + # тем, что уехало в сыром JSON. + for number, block in enumerate(raw_of(orders)): + action = block[commerce.PURCHASE_ACTION]["actionField"] + assert action["revenue"] == orders["purchaseRevenue"][number][0] + assert ("coupon" in action) == bool(coupons[number]) + + +def test_editing_the_trade_flow_does_not_move_the_traffic( + monkeypatch: pytest.MonkeyPatch, +): + """Иерархия подпотоков: у торговли своя случайность (спека, раздел 2).""" + ours = day.stream(CANONICAL_SEED, WEEKDAY) + plan.cohort.cache_clear() + monkeypatch.setattr(world, "ABANDONED_POSITION_PERCENT", 40) + other = day.stream(CANONICAL_SEED, WEEKDAY) + + def pageviews(events: day.Day) -> dict[str, list[Any]]: + rows = rows_of(events, "pageview") + return { + name: [ + cell.tolist() if isinstance(cell, np.ndarray) else cell + for cell in value + ] + for name, value in rows.items() + } + + assert pageviews(other) == pageviews(ours) + assert [ + cell.tolist() for cell in rows_of(other, commerce.PURCHASE)["productID"] + ] != [cell.tolist() for cell in rows_of(ours, commerce.PURCHASE)["productID"]] + + +def test_the_trade_flow_draws_from_its_own_named_branch( + monkeypatch: pytest.MonkeyPatch, +): + """Торговля берёт подпоток `COMMERCE` — именно его, а не любой другой. + + Неподвижности трафика для этого мало: возьми торговля ветвь трафика, + трафик всё равно не сдвинулся бы — свои броски он сделал раньше и из + своего генератора. Разошлись бы только два подпотока, ставшие одним, а + в дереве зерна место каждого компонента и есть контракт (спека, + раздел 2). Поэтому проверяется адрес, по которому торговля пришла. + """ + asked: list[tuple[int, int, Component]] = [] + honest = commerce.day_stream + + def spy(seed: int, day_number: int, component: Component): + asked.append((seed, day_number, component)) + return honest(seed, day_number, component) + + monkeypatch.setattr(commerce, "day_stream", spy) + day.stream(CANONICAL_SEED, WEEKDAY) + assert asked == [(CANONICAL_SEED, WEEKDAY, Component.COMMERCE)] + + +def test_the_promised_orders_of_a_pair_become_purchases(week: list[day.Day]): + """Гарантия плана: обе куки пары покупают в назначенные им дни. + + Проверяются пары, у которых оба назначенных дня попали внутрь недели: + остальные ждут своего дня за горизонтом. + """ + bought = [ + set(rows_of(events, commerce.PURCHASE)["ClientID"].tolist()) for events in week + ] + pairs = 0 + for born in range(-world.RETURN_TAIL_DAYS, WEEK): + cohort = plan.cohort(CANONICAL_SEED, born) + for cookies, days in zip( + cohort.pair_cookies.tolist(), cohort.pair_order_days.tolist(), strict=True + ): + if max(days) >= WEEK: + continue + pairs += 1 + for cookie, number in zip(cookies, days, strict=True): + assert int(cohort.client_id[cookie]) in bought[number] + assert pairs > 10 + # Счётчик пар в плане и пары, реализованные в дне, — про одно и то же. + assert pairs == plan.counters(CANONICAL_SEED, WEEK).pairs + + +def paired_cookies() -> set[int]: + """Куки двухкуковых пар: их заказы обещаны планом, а не решены днём.""" + cookies: set[int] = set() + for born in range(-world.RETURN_TAIL_DAYS, WEEK): + cohort = plan.cohort(CANONICAL_SEED, born) + cookies |= set(cohort.client_id[cohort.pair_cookies.ravel()].tolist()) + return cookies + + +def test_flagged_buyers_buy_again_much_more_often(week: list[day.Day]): + """Метка покупателя перестала быть словом: у неё виден след в данных. + + «Постоянный покупатель» читается только так — как кука, которая ходит + неделями и покупает не раз: метки в событии нет и не будет. + + Куки двухкуковых пар из счёта исключены: их заказы назначил план, и + вместе с ними лифт мерил бы гарантию, а не два новых рычага. Остаются + те помеченные, чьи покупки решил день: повторно покупают 4,6% из них + против 1,5% прочих — втрое чаще при пороге «вдвое». + """ + purchases: dict[int, int] = {} + flagged: set[int] = set() + for number, events in enumerate(week): + audience = plan.audience(CANONICAL_SEED, number) + flagged |= set(audience.client_id[audience.buyer].tolist()) + for cookie in rows_of(events, commerce.PURCHASE)["ClientID"].tolist(): + purchases[cookie] = purchases.get(cookie, 0) + 1 + + def repeat_share(cookies: set[int]) -> float: + again = sum(1 for cookie in cookies if purchases[cookie] > 1) + return again / len(cookies) + + buyers = set(purchases) + by_levers = (buyers & flagged) - paired_cookies() + others = buyers - flagged + assert len(by_levers) > 100 + assert len(others) > 100 + assert repeat_share(by_levers) > 2 * repeat_share(others) + + +def test_the_shop_stays_the_same_plausible_shop(week: list[day.Day]): + """Границы правки — вилки спеки (раздел 5): аудитория, объём, конверсия.""" + counters = plan.counters(CANONICAL_SEED, WEEK) + assert all(6_000 <= size <= 8_000 for size in counters.audience) + for events in week: + visits = len(set(events.columns["VisitID"].tolist())) + orders = int((events.columns["EventType"] == commerce.PURCHASE).sum()) + assert 40_000 < len(events) < 60_000 + assert 0.015 < orders / visits < 0.035 diff --git a/generator/tests/test_day.py b/generator/tests/test_day.py index 6f0f8c7..b91ef5c 100644 --- a/generator/tests/test_day.py +++ b/generator/tests/test_day.py @@ -1,4 +1,4 @@ -"""День-функция: чистота, форма волны, правила резки визитов, шов для #40. +"""День-функция: чистота, форма волны, правила резки визитов, шов с торговлей. Числа мира тесты сторожат вилками спеки, а не точными значениями: менти крутит конфигурацию, и падать тесты должны там, где сдвинулся вывод («средний @@ -14,7 +14,16 @@ import numpy as np import pytest from numpy.typing import NDArray -from clickstream_generator import catalog, day, plan, reference, schema, world +from clickstream_generator import ( + catalog, + commerce, + day, + ids, + plan, + reference, + schema, + world, +) from clickstream_generator.reference import Page from clickstream_generator.seeds import CANONICAL_SEED @@ -137,25 +146,36 @@ def test_every_column_of_the_contract_is_present_and_typed(weekday: day.Day): assert values.dtype == np.dtype(column.numpy_dtype), column.name -def test_what_is_left_to_the_trade_ticket_is_empty_not_missing(weekday: day.Day): +def test_a_pageview_carries_the_trade_columns_empty_not_missing(weekday: day.Day): """Пусто — пустой массив и пустая строка, а ключ есть у каждого события. - Проверяются все колонки будущих торговых событий, а не выбранные: тикет - #40 добавит свои, и они должны попасть под тот же сторож. + Проверяются все торговые колонки, а не выбранные: у просмотра страницы + пусты они все до одной, иначе строгий приём хранилища не уживётся с + полями, пустыми по смыслу (мастер-спека, раздел 6). """ - waiting = [ + pageview = weekday.columns["EventType"] == "pageview" + assert pageview.mean() > 0.9 + trade = [ column for column in schema.COLUMNS if column.group in (schema.ColumnGroup.ECOMMERCE, schema.ColumnGroup.PARAMS) ] - assert len(waiting) > 10 - for column in waiting: - cells = weekday.columns[column.name][:1000] + assert len(trade) > 10 + for column in trade: + cells = weekday.columns[column.name][pageview][:1000] if column.clickhouse_type.startswith("Array("): assert all(cell.size == 0 for cell in cells), column.name else: assert set(cells) == {""}, column.name - assert set(weekday.columns["EventType"].tolist()) == {"pageview"} + + +def test_the_taxonomy_is_three_event_types(weekday: day.Day): + """`EventType` — добавка стенда: таксономия нужна явно (мастер-спека, 1.1).""" + assert set(weekday.columns["EventType"].tolist()) == { + "pageview", + commerce.ADD_TO_CART, + commerce.PURCHASE, + } def test_no_column_hides_a_hole(weekday: day.Day): @@ -171,7 +191,7 @@ def test_no_column_hides_a_hole(weekday: day.Day): def test_identifiers_survive_json(weekday: day.Day): """Числа выше 2^53 в JSON округляются — идентификаторам столько не нужно.""" for name in ("WatchID", "VisitID", "ClientID"): - assert weekday.columns[name].max() < day.ID_LIMIT + assert weekday.columns[name].max() < ids.LIMIT assert weekday.columns[name].dtype == np.uint64 @@ -229,7 +249,7 @@ def test_the_counter_timezone_moves_the_date_apart_from_utc(weekday: day.Day): def test_the_scale_of_an_average_day_is_about_fifty_thousand(weekday: day.Day): - """Порядок величины (спека, раздел 5); итог сложится после #40.""" + """Порядок величины: ~50 тыс. событий в средний день (спека, раздел 5).""" assert 30_000 < len(weekday) < 70_000 visits = len(set(weekday.columns["VisitID"].tolist())) assert 6_000 < visits < 14_000 @@ -256,7 +276,7 @@ def test_the_weekend_is_shaped_unlike_a_weekday(weekday: day.Day): def test_the_funnel_converts_about_two_percent_of_visits(weekday: day.Day): visits = len(set(weekday.columns["VisitID"].tolist())) - ordered = int((weekday.page == Page.CONFIRMATION).sum()) + ordered = int((weekday.columns["EventType"] == commerce.PURCHASE).sum()) assert 0.01 < ordered / visits < 0.04 @@ -270,7 +290,7 @@ def test_the_promised_orders_reach_the_confirmation(weekday: day.Day): def test_the_cart_always_follows_a_product_card(weekday: day.Day): - """Шов для #40: товар в корзине посетитель до того открывал.""" + """Шов с торговлей: товар в корзине посетитель до того открывал.""" order = np.lexsort((local_seconds(weekday), weekday.columns["VisitID"])) by_visit = weekday.page[order] carts = np.flatnonzero(by_visit == Page.CART) @@ -292,11 +312,17 @@ def test_the_product_of_a_card_is_the_seam_for_trade_events(weekday: day.Day): def test_the_referer_is_the_page_before(weekday: day.Day): - """Внутри визита реферер — предыдущий адрес; на входе — адрес источника.""" - order = np.lexsort((local_seconds(weekday), weekday.columns["VisitID"])) - url = weekday.columns["URL"][order] - referer = weekday.columns["Referer"][order] - visit = weekday.columns["VisitID"][order] + """Внутри визита реферер — предыдущий адрес; на входе — адрес источника. + + Цепочка считается по просмотрам страниц: торговое событие не открывает + страницу, а живёт на уже открытой, и реферер у него тот же, что у неё. + """ + pageview = weekday.columns["EventType"] == "pageview" + columns = {name: value[pageview] for name, value in weekday.columns.items()} + order = np.lexsort((columns["UTCEventTime"], columns["VisitID"])) + url = columns["URL"][order] + referer = columns["Referer"][order] + visit = columns["VisitID"][order] inside = np.flatnonzero(visit[1:] == visit[:-1]) + 1 assert np.all(referer[inside] == url[inside - 1]) diff --git a/generator/tests/test_ids.py b/generator/tests/test_ids.py new file mode 100644 index 0000000..9da4497 --- /dev/null +++ b/generator/tests/test_ids.py @@ -0,0 +1,41 @@ +"""Идентификаторы событий: неповторяющиеся и переживающие JSON. + +Обещание одно, но держит его дедупликация при переигровке дня: два +одинаковых `WatchID` склеили бы разные события (спека, раздел 4). +""" + +import numpy as np + +from clickstream_generator import ids +from clickstream_generator.seeds import CANONICAL_SEED + + +def stream(offset: int = 0) -> np.random.Generator: + return np.random.Generator(np.random.PCG64(CANONICAL_SEED + offset)) + + +def test_ids_do_not_repeat_and_survive_json(): + numbers = ids.unique(stream(), 10_000) + assert numbers.dtype == np.uint64 + assert len(set(numbers.tolist())) == numbers.size + assert numbers.max() < ids.LIMIT + assert numbers.min() > 0 + + +def test_ids_do_not_give_away_the_order_of_the_rows(): + """Иначе номер события рассказывал бы, каким по счёту оно родилось.""" + numbers = ids.unique(stream(), 1_000) + assert not np.all(np.diff(numbers.astype(np.int64)) > 0) + + +def test_ids_of_another_stream_are_other_ids(): + assert not np.array_equal(ids.unique(stream(), 100), ids.unique(stream(1), 100)) + + +def test_ids_can_be_drawn_beside_the_ones_already_taken(): + """Торговые строки берут номера из своего подпотока и не задевают чужие.""" + taken = ids.unique(stream(), 5_000) + numbers = ids.unique_apart_from(stream(1), 500, taken) + assert len(set(numbers.tolist())) == numbers.size + assert not set(numbers.tolist()) & set(taken.tolist()) + assert numbers.max() < ids.LIMIT diff --git a/generator/tests/test_world.py b/generator/tests/test_world.py index 3521a20..5724f07 100644 --- a/generator/tests/test_world.py +++ b/generator/tests/test_world.py @@ -15,6 +15,13 @@ def mean_by_weights(values: tuple[int, ...], weights: tuple[int, ...]) -> float: return weighted / sum(weights) +def active_days(one_shot_percent: int, return_weights: tuple[int, ...]) -> float: + """Сколько дней в среднем ходит кука: день рождения плюс возвраты.""" + counts = tuple(range(1, len(return_weights) + 1)) + returns = mean_by_weights(counts, return_weights) + return 1 + (100 - one_shot_percent) / 100 * returns + + def test_origin_is_a_monday(): """День недели считается как остаток номера дня — это верно от понедельника.""" assert world.ORIGIN.weekday() == 0 @@ -36,10 +43,7 @@ def test_returns_average_three_to_four(): def test_average_cookie_is_active_about_two_days(): """Сходимость с разделом 5: ≈1,9 активного дня на куку — отсюда 6–8 тыс.""" - counts = tuple(range(1, len(world.RETURN_COUNT_WEIGHTS) + 1)) - returns = mean_by_weights(counts, world.RETURN_COUNT_WEIGHTS) - active_days = 1 + (100 - world.ONE_SHOT_PERCENT) / 100 * returns - assert 1.8 <= active_days <= 2.0 + assert 1.8 <= active_days(world.ONE_SHOT_PERCENT, world.RETURN_COUNT_WEIGHTS) <= 2.0 def test_return_delays_cover_the_whole_activity_window(): @@ -108,12 +112,50 @@ def test_pauses_stay_inside_the_visit_timeout(): assert max(world.LONG_PAUSE_SECONDS) < world.VISIT_TIMEOUT_SECONDS -def test_the_funnel_converts_about_two_percent_of_visits(): - """Конверсия ~2% на сессию (спека, раздел 9) — произведение трёх шагов.""" - conversion = ( - world.CART_PERCENT - * world.CHECKOUT_OF_CART_PERCENT - * world.CONFIRMATION_OF_CHECKOUT_PERCENT - / 100**2 - ) - assert 1.5 <= conversion <= 2.5 +def test_the_trade_event_never_overtakes_the_next_page(): + """Торговое событие отстаёт от своей страницы меньше, чем длится пауза. + + На этом стоят два свойства сразу: событие корзины не обгоняет страницу, + на которой посетитель нажал кнопку, и не выходит за полночь — за ним в + том же визите всегда идёт страница, которая полночь пережила. + """ + assert max(world.TRADE_DELAY_SECONDS) <= min(world.PAGE_PAUSE_SECONDS) + + +def conversion(cart: int, checkout_of_cart: int) -> float: + """Конверсия визита в заказ, проценты: произведение трёх шагов воронки.""" + return cart * checkout_of_cart * world.CONFIRMATION_OF_CHECKOUT_PERCENT / 100**2 + + +def test_the_ordinary_visitor_converts_a_bit_below_the_world(): + """Воронка обычного посетителя: ~1,5% визитов в оформленный заказ. + + До ~2% конверсию мира (спека, раздел 9) добирают помеченные покупатели + и обещанные планом заказы пар, поэтому здесь сторожится слагаемое, а не + итог: сам итог меряет день-функция на собранном дне + (`test_the_shop_stays_the_same_plausible_shop`). + """ + assert 1.2 <= conversion(world.CART_PERCENT, world.CHECKOUT_OF_CART_PERCENT) <= 1.8 + + +def test_the_buyer_mark_shows_on_both_steps_of_the_funnel(): + """Один шаг дал бы половину картины: кладут реже и бросают чаще оба.""" + assert world.BUYER_CART_PERCENT > world.CART_PERCENT + assert world.BUYER_CHECKOUT_OF_CART_PERCENT > world.CHECKOUT_OF_CART_PERCENT + lift = conversion( + world.BUYER_CART_PERCENT, world.BUYER_CHECKOUT_OF_CART_PERCENT + ) / conversion(world.CART_PERCENT, world.CHECKOUT_OF_CART_PERCENT) + # Заметно чаще прочих, но не «покупают только помеченные»: 5% людей дали + # бы тогда около 40 заказов в день вместо 240 (спека, раздел 9). + assert 2 <= lift <= 6 + + +def test_the_buyer_mark_makes_the_cookie_live_longer(): + """Второй рычаг метки: без долгой жизни второй покупке негде случиться.""" + assert world.BUYER_ONE_SHOT_PERCENT < world.ONE_SHOT_PERCENT + usual = active_days(world.ONE_SHOT_PERCENT, world.RETURN_COUNT_WEIGHTS) + buyer = active_days(world.BUYER_ONE_SHOT_PERCENT, world.BUYER_RETURN_COUNT_WEIGHTS) + assert buyer > 2 * usual + # Но не бессмертие: дневная аудитория остаётся в вилке 6–8 тыс., а + # помеченных всего 5% людей когорты. + assert buyer < world.RETURN_TAIL_DAYS / 10 diff --git a/generator/uv.lock b/generator/uv.lock index 05b1928..63c6dd5 100644 --- a/generator/uv.lock +++ b/generator/uv.lock @@ -8,6 +8,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "numpy" }, + { name = "orjson" }, ] [package.dev-dependencies] @@ -18,7 +19,10 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "numpy", specifier = ">=2" }] +requires-dist = [ + { name = "numpy", specifier = ">=2" }, + { name = "orjson", specifier = ">=3.11.9" }, +] [package.metadata.requires-dev] dev = [ @@ -74,6 +78,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, ] +[[package]] +name = "orjson" +version = "3.11.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, + { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, + { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, +] + [[package]] name = "packaging" version = "26.2" -- 2.54.0 From fbffeda5eae6846b58aa21a25a5d3b04eaa7f340 Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Sun, 2 Aug 2026 21:06:51 +0300 Subject: [PATCH 5/8] =?UTF-8?q?docs(generator):=20=D1=80=D0=B5=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=B3=D1=80=D0=B8=D0=BB=D0=B8=D0=BD=D0=B3?= =?UTF-8?q?=D0=B0=20=E2=80=94=20=D0=BA=D0=BE=D1=80=D0=B7=D0=B8=D0=BD=D0=B0?= =?UTF-8?q?=20=D0=B8=20=D1=83=D1=80=D0=BE=D0=B2=D0=B5=D0=BD=D1=8C=20=D1=81?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=81=D0=B0=20=D1=82=D0=BE=D0=B2=D0=B0=D1=80?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - приёмка #40 нашла в торговых данных два точных равенства, каких в живом магазине не бывает: событие корзины возникало ровно у визитов со страницей /cart, а внутри такого визита в корзину ложились все открытые карточки. - Что: - в спеку генератора, раздел 9, добавлен блок «Решено при исполнении #40 после приёмки»: корзина отвязана от страницы корзины, уровень спроса становится свойством товара, границы правки — вилки раздела 5. - записаны отклонённые варианты: свой процент у каждого sku и вывод склонности из цены. - в CONTEXT.md заведён термин «уровень спроса». - Проверка: - git show --stat; текст решений читается без обращения к переписке. --- CONTEXT.md | 6 +++++ docs/specs/2026-08-01-generator.md | 42 ++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/CONTEXT.md b/CONTEXT.md index 61637b2..daef449 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -84,6 +84,12 @@ _Избегать_: «покупатель» про того, кто купил `data/catalog/products.csv` — общий справочник генератора и словаря ClickHouse. Форма файла решена, длина — нет: строки дописываются. +**Уровень спроса**: +Свойство товара в каталоге: как часто открытую карточку кладут в корзину. +Три значения — магнит, обычный, залёживается. Постоянная часть мира, а не +поведение дня: он и делает конверсию «просмотр → корзина» по товарам и +брендам различимой. + **Ось модельного времени**: Собственный календарь мира генератора. Дни считаются от фиксированного первого дня D0; реальный календарь в модели не участвует. Между прогонами diff --git a/docs/specs/2026-08-01-generator.md b/docs/specs/2026-08-01-generator.md index 49ae360..c7b6f9a 100644 --- a/docs/specs/2026-08-01-generator.md +++ b/docs/specs/2026-08-01-generator.md @@ -719,6 +719,48 @@ pytest-тест с маркером `perf` и таймаутом-обрубан которого в плоских колонках нет; строки живут в справочниках рядом с устройствами и городами, а не сочиняются на месте. +Решено при исполнении #40 после приёмки (2026-08-02) — грилинг тождества +«просмотр → корзина». Приёмка нашла два точных равенства: каждое в живых +данных не встречается и потому читается аналитиком как склейка в разметке, +а не как поведение людей. + +- **Корзина отвязана от страницы корзины.** Замерено до правки (день 2 + канонического мира): множества «визиты с событием корзины» и «визиты со + страницей `/cart`» совпадали в точности — 802 и 802. Событие корзины + возникало только у визита, дошедшего до воронки, то есть факт добавления + идеально предсказывался более поздним просмотром страницы. Теперь + положить может любой визит, открывший карточку; страница `/cart` + остаётся шагом воронки и решается как прежде, а заказ по-прежнему + рисуется только у визита, дошедшего до подтверждения. Оговорка, без + которой появилась бы новая ложь вместо старой: у визита, дошедшего до + `/cart`, хотя бы одна позиция в корзине есть всегда — страница корзины + при пустой корзине невозможна. Брошенные корзины в мире были и до правки + (в том же дне 802 корзины против 272 покупок); правка добавляет к ним + тех, кто положил и корзину не открывал, — самый массовый сюжет магазина. + Почему это проехало при нарезке: воронка #39 записана в терминах + страниц, #40 принял решение про события («событие корзины садится на + карточку»), но вопрос «какой визит вообще кладёт» заново не задал — + страничное правило молча переехало на уровень событий. +- **Класть или нет решает и товар, а не только визит.** До правки в + корзину ложились все карточки покупающего визита: внутри воронки 100%, + вне её 0%. Привлекательность товара измерить было нечем — конверсия + «просмотр → корзина» по sku давала чистый шум, а вопрос «какие товары + кладут чаще» ответа не имел. Теперь у товара есть уровень спроса: + колонка каталога с тремя значениями (магнит, обычный, залёживается), а + вероятности трёх уровней — числа мира рядом с остальными. Отклонено: + *свой процент у каждого из 180 sku* — рукописные числа, которых никто не + обоснует, а каталог по решению #39 дописывается механически; *выводить + склонность из цены* — жёсткая связь, в которой менти найдёт ровно то, + что мы в неё вложили, и ничего сверх. +- **Границы правки — те же вилки раздела 5**: аудитория 6–8 тыс., средний + день около 50 тыс. событий (сейчас 49,5 тыс. — запас невелик, и рост + событий корзины считается против него), конверсия визита около 2%. + Заказ рисуется из корзины, а корзина сузилась, поэтому доля брошенных + позиций (15%) и состав заказа (1,9 позиции) перемеряются вместе с + остальным: сохраняется не число, а свойство — заказ уже корзины и + однопозиционных заказов около половины. Числа канонического мира после + правки перемеряются и переписываются. + Остаётся открытым, за тикетами: - интерфейс запуска генератора (CLI / цели make) и как он делит режимы -- 2.54.0 From 4165f10cddb6ef814f6b3db24ba226f910f85e94 Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Sun, 2 Aug 2026 21:21:43 +0300 Subject: [PATCH 6/8] =?UTF-8?q?docs(generator):=20=D1=87=D0=B8=D1=81=D0=BB?= =?UTF-8?q?=D0=B0=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B0=20=D0=B3=D1=80=D0=B8?= =?UTF-8?q?=D0=BB=D0=B8=D0=BD=D0=B3=D0=B0=20=E2=80=94=20=D1=81=20=D0=BA?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=BD=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D0=B7=D0=B5=D1=80=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - блок «Решено при исполнении #40 после приёмки» цитировал замер на зерне 0, а канонический мир, на котором стоят все тесты, — CANONICAL_SEED (20260601); числа относились к другому миру. - Что: - замер перемерен на каноническом зерне за 14 дней: тождество «событие корзины ↔ страница /cart» держится каждый день, на дне 2 это 828 и 828. - уточнены средний день (49 509 событий), конверсия визита (2,43%), состав заказа (1,87 позиции) и доля брошенных позиций (число мира 15%, замер по покупающим корзинам 12%). - Проверка: - скрипт замера на 14 днях CANONICAL_SEED; git show --stat. --- docs/specs/2026-08-01-generator.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/specs/2026-08-01-generator.md b/docs/specs/2026-08-01-generator.md index c7b6f9a..8e53cd8 100644 --- a/docs/specs/2026-08-01-generator.md +++ b/docs/specs/2026-08-01-generator.md @@ -724,9 +724,10 @@ pytest-тест с маркером `perf` и таймаутом-обрубан данных не встречается и потому читается аналитиком как склейка в разметке, а не как поведение людей. -- **Корзина отвязана от страницы корзины.** Замерено до правки (день 2 - канонического мира): множества «визиты с событием корзины» и «визиты со - страницей `/cart`» совпадали в точности — 802 и 802. Событие корзины +- **Корзина отвязана от страницы корзины.** Замерено до правки на всех 14 + днях канонического мира: множества «визиты с событием корзины» и «визиты + со страницей `/cart`» совпадали в точности — 828 и 828 на дне 2, и так + каждый день без единого расхождения. Событие корзины возникало только у визита, дошедшего до воронки, то есть факт добавления идеально предсказывался более поздним просмотром страницы. Теперь положить может любой визит, открывший карточку; страница `/cart` @@ -735,7 +736,7 @@ pytest-тест с маркером `perf` и таймаутом-обрубан которой появилась бы новая ложь вместо старой: у визита, дошедшего до `/cart`, хотя бы одна позиция в корзине есть всегда — страница корзины при пустой корзине невозможна. Брошенные корзины в мире были и до правки - (в том же дне 802 корзины против 272 покупок); правка добавляет к ним + (в том же дне 828 корзин против 272 покупок); правка добавляет к ним тех, кто положил и корзину не открывал, — самый массовый сюжет магазина. Почему это проехало при нарезке: воронка #39 записана в терминах страниц, #40 принял решение про события («событие корзины садится на @@ -753,10 +754,11 @@ pytest-тест с маркером `perf` и таймаутом-обрубан склонность из цены* — жёсткая связь, в которой менти найдёт ровно то, что мы в неё вложили, и ничего сверх. - **Границы правки — те же вилки раздела 5**: аудитория 6–8 тыс., средний - день около 50 тыс. событий (сейчас 49,5 тыс. — запас невелик, и рост - событий корзины считается против него), конверсия визита около 2%. - Заказ рисуется из корзины, а корзина сузилась, поэтому доля брошенных - позиций (15%) и состав заказа (1,9 позиции) перемеряются вместе с + день около 50 тыс. событий (сейчас 49 509 — запас невелик, и рост + событий корзины считается против него), конверсия визита около 2% + (сейчас 2,43%). Заказ рисуется из корзины, а корзина сузилась, поэтому + доля брошенных позиций (число мира — 15%, замер по покупающим корзинам — + 12%) и состав заказа (1,87 позиции) перемеряются вместе с остальным: сохраняется не число, а свойство — заказ уже корзины и однопозиционных заказов около половины. Числа канонического мира после правки перемеряются и переписываются. -- 2.54.0 From c0f4d21df944715e4c3844765d0c61e7091c6ddb Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Sun, 2 Aug 2026 22:46:30 +0300 Subject: [PATCH 7/8] =?UTF-8?q?feat(generator):=20=D0=BA=D0=BE=D1=80=D0=B7?= =?UTF-8?q?=D0=B8=D0=BD=D0=B0=20=D0=BE=D1=82=D0=B2=D1=8F=D0=B7=D0=B0=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BE=D1=82=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=86=D1=8B,=20=D1=83=20=D1=82=D0=BE=D0=B2=D0=B0=D1=80=D0=B0?= =?UTF-8?q?=20=E2=80=94=20=D1=83=D1=80=D0=BE=D0=B2=D0=B5=D0=BD=D1=8C=20?= =?UTF-8?q?=D1=81=D0=BF=D1=80=D0=BE=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - приёмка #40 нашла в торговых данных два точных равенства, каких в живом магазине не бывает: событие корзины случалось ровно у визитов со страницей /cart, а внутри такого визита в корзину уходили все открытые карточки. Привлекательность товара было нечем измерить, а аналитик читал бы эти равенства как склейку в разметке. - Что: - класть в корзину может любой визит, открывший карточку; страница /cart осталась шагом воронки, а у визита, дошедшего до неё, корзина непуста. - у товара появился уровень спроса — колонка каталога с тремя значениями и два ряда вероятностей в числах мира: намерение визита берётся из шага воронки, а не из метки покупателя, которая уже действует через неё. - уровни рассыпаны по каталогу одной колодой: одинакового расклада по категориям нет, связи с ценой нет, и то и другое сторожится тестами. - числа мира перемерены: конверсия карточки в корзину 8,62% (по уровням 13,1 / 9,2 / 5,8), кладут без открытия корзины 41,4% таких визитов, средний день 49 834 события, конверсия визита в покупку 2,43%. - спека генератора и мастер-спека приведены к новой форме каталога. - Проверка: - make lint && make typecheck && make test — 394 passed (было 383). - трафиковая половина побайтово та же: sha256 по (URL, времени, WatchID) всех просмотров за 14 канонических дней совпадает со снимком до правки. --- data/catalog/products.csv | 362 +++++++++--------- docs/specs/2026-07-30-stand-v2-realism.md | 10 +- docs/specs/2026-08-01-generator.md | 92 ++++- .../src/clickstream_generator/catalog.py | 31 +- .../src/clickstream_generator/commerce.py | 130 +++++-- generator/src/clickstream_generator/day.py | 5 +- generator/src/clickstream_generator/world.py | 31 +- generator/tests/test_catalog.py | 92 +++++ generator/tests/test_commerce.py | 210 +++++++++- generator/tests/test_world.py | 19 +- 10 files changed, 734 insertions(+), 248 deletions(-) diff --git a/data/catalog/products.csv b/data/catalog/products.csv index 5f72864..63ebfbf 100644 --- a/data/catalog/products.csv +++ b/data/catalog/products.csv @@ -1,181 +1,181 @@ -sku,name,category,brand,price -HOME-0001,Комплект органайзеров для шкафа,Товары для дома,Домовит,129000 -HOME-0002,Сушилка для белья напольная,Товары для дома,Домовит,249900 -HOME-0003,Настольная лампа «Тихий вечер»,Товары для дома,Ладный дом,188990 -HOME-0004,Набор вешалок 12 штук,Товары для дома,Домовит,79000 -TEXT-0001,Комплект постельного белья полутораспальный,Текстиль,Сатин,348990 -TEXT-0002,Плед-покрывало 200×220,Текстиль,Сатин,279000 -TEXT-0003,Полотенце банное 70×140,Текстиль,Сатин,89000 -TEXT-0004,Штора блэкаут 200×260,Текстиль,Ладный дом,218990 -POSU-0001,Сковорода 26 см с антипригарным покрытием,Посуда,Кухмастер,198990 -POSU-0002,Набор кастрюль 3 предмета,Посуда,Кухмастер,458999 -POSU-0003,Чайник заварочный стеклянный 1 л,Посуда,Кухмастер,118990 -POSU-0004,Набор столовых приборов 24 предмета,Посуда,Ладья,289000 -TECH-0001,Электрочайник 1.7 л,Бытовая техника,Электрон,188990 -TECH-0002,Блендер погружной 800 Вт,Бытовая техника,Электрон,279000 -TECH-0003,Отпариватель ручной,Бытовая техника,Электрон,349000 -TECH-0004,Робот-пылесос,Бытовая техника,Электрон,1899000 -KIDS-0001,Стульчик для кормления,Детские товары,Малышок,649000 -KIDS-0002,Ночник-проектор «Звёзды»,Детские товары,Малышок,128990 -KIDS-0003,Набор детской посуды 5 предметов,Детские товары,Малышок,88999 -WEAR-0001,Тапочки домашние войлочные,Одежда и обувь,Ходики,98990 -WEAR-0002,Халат махровый,Одежда и обувь,Сатин,249000 -WEAR-0003,Носки хлопковые 5 пар,Одежда и обувь,Ходики,59000 -HOME-0005,Корзина для белья 45 л,Товары для дома,Домовит,119000 -HOME-0006,Контейнер для хранения 18 л,Товары для дома,Домовит,69000 -HOME-0007,Чехол для одежды 60×100,Товары для дома,Ладный дом,38990 -HOME-0008,Коврик придверный резиновый 40×60,Товары для дома,Домовит,59000 -HOME-0009,Гладильная доска настольная 73×30,Товары для дома,Ладный дом,149000 -HOME-0010,Ведро хозяйственное с крышкой 12 л,Товары для дома,Домовит,48990 -HOME-0011,Швабра с отжимом и ведром 8 л,Товары для дома,Домовит,178990 -HOME-0012,Щётка для пола с черенком 120 см,Товары для дома,Домовит,45000 -HOME-0013,Совок со щёткой на длинной ручке,Товары для дома,Домовит,79000 -HOME-0014,Таз хозяйственный круглый 14 л,Товары для дома,Домовит,38990 -HOME-0015,Этажерка для ванной 3 яруса,Товары для дома,Ладный дом,139000 -HOME-0016,Полка настенная металлическая 45 см,Товары для дома,Ладный дом,99000 -HOME-0017,Зеркало настенное круглое 50 см,Товары для дома,Ладный дом,168990 -HOME-0018,Набор коробок для обуви 6 штук,Товары для дома,Домовит,129000 -HOME-0019,Подставка для зонтов металлическая,Товары для дома,Ладный дом,158990 -HOME-0020,Крючки самоклеящиеся 8 штук,Товары для дома,Домовит,29000 -HOME-0021,Верёвка бельевая 20 м,Товары для дома,Домовит,19000 -HOME-0022,Прищепки усиленные 24 штуки,Товары для дома,Домовит,24990 -HOME-0023,Мешки вакуумные для одежды 5 штук,Товары для дома,Домовит,68999 -HOME-0024,Органайзер подвесной с 12 карманами,Товары для дома,Ладный дом,79000 -HOME-0025,Часы настенные круглые 30 см,Товары для дома,Ладный дом,88990 -HOME-0026,Светильник настольный с прищепкой 7 Вт,Товары для дома,Ладный дом,119000 -HOME-0027,Удлинитель бытовой 3 розетки 3 м,Товары для дома,Ладный дом,99000 -HOME-0028,Лестница-стремянка 3 ступени,Товары для дома,Домовит,299000 -HOME-0029,Ящик для инструментов 16 дюймов,Товары для дома,Домовит,138990 -HOME-0030,Набор плечиков для брюк 6 штук,Товары для дома,Домовит,89000 -TEXT-0005,Пододеяльник двуспальный 175×215,Текстиль,Сатин,169000 -TEXT-0006,Простыня на резинке 160×200,Текстиль,Сатин,148990 -TEXT-0007,Наволочки хлопковые 50×70 2 штуки,Текстиль,Сатин,79000 -TEXT-0008,Одеяло всесезонное 172×205,Текстиль,Сатин,299000 -TEXT-0009,Подушка стёганая 50×70,Текстиль,Сатин,128990 -TEXT-0010,Наматрасник непромокаемый 160×200,Текстиль,Сатин,189000 -TEXT-0011,Покрывало стёганое 180×210,Текстиль,Сатин,238999 -TEXT-0012,Плед флисовый 150×200,Текстиль,Сатин,118990 -TEXT-0013,Полотенце лицевое 50×90,Текстиль,Сатин,49000 -TEXT-0014,Полотенца кухонные 40×60 3 штуки,Текстиль,Сатин,59000 -TEXT-0015,Коврик для ванной хлопковый 50×80,Текстиль,Ладный дом,99000 -TEXT-0016,Штора для ванной 180×200,Текстиль,Ладный дом,79000 -TEXT-0017,Тюль вуаль 300×260,Текстиль,Ладный дом,199000 -TEXT-0018,Шторы кухонные 150×170 2 полотна,Текстиль,Ладный дом,179000 -TEXT-0019,Скатерть водоотталкивающая 140×180,Текстиль,Ладный дом,129000 -TEXT-0020,Дорожка на стол хлопковая 40×140,Текстиль,Ладный дом,59000 -TEXT-0021,Салфетки столовые льняные 4 штуки,Текстиль,Сатин,79000 -TEXT-0022,Чехлы на табурет 35×35 4 штуки,Текстиль,Ладный дом,108990 -TEXT-0023,Подушка декоративная 40×40,Текстиль,Сатин,68999 -TEXT-0024,Чехол для диванной подушки 45×45,Текстиль,Сатин,39000 -TEXT-0025,Фартук кухонный с карманом,Текстиль,Ладный дом,59000 -TEXT-0026,Прихватки стёганые 20×20 2 штуки,Текстиль,Ладный дом,38990 -TEXT-0027,Халат вафельный размер 48–50,Текстиль,Сатин,199000 -TEXT-0028,Комплект полотенец 50×90 и 70×140,Текстиль,Сатин,138999 -TEXT-0029,Одеяло детское байковое 100×140,Текстиль,Сатин,169000 -TEXT-0030,Подушка для стула 40×40,Текстиль,Ладный дом,59000 -POSU-0005,Кастрюля эмалированная 3 л,Посуда,Кухмастер,168990 -POSU-0006,Ковш нержавеющий с крышкой 1.5 л,Посуда,Кухмастер,138990 -POSU-0007,Сотейник литой с крышкой 28 см,Посуда,Кухмастер,269000 -POSU-0008,Сковорода блинная 22 см,Посуда,Кухмастер,149000 -POSU-0009,Казан алюминиевый 6 л,Посуда,Кухмастер,299000 -POSU-0010,Форма для запекания стеклянная 2.4 л,Посуда,Кухмастер,118990 -POSU-0011,Противень стальной 37×27,Посуда,Кухмастер,79000 -POSU-0012,Набор форм для выпечки 3 штуки,Посуда,Кухмастер,139000 -POSU-0013,Миска нержавеющая 24 см,Посуда,Кухмастер,68990 -POSU-0014,Дуршлаг стальной с ручками 22 см,Посуда,Кухмастер,89000 -POSU-0015,Тёрка четырёхгранная 23 см,Посуда,Кухмастер,49000 -POSU-0016,Доска разделочная бамбуковая 35×25,Посуда,Кухмастер,78990 -POSU-0017,Набор ножей с подставкой 6 предметов,Посуда,Кухмастер,248990 -POSU-0018,Половник и шумовка 2 предмета,Посуда,Кухмастер,58990 -POSU-0019,Контейнеры пищевые 7 предметов,Посуда,Кухмастер,99000 -POSU-0020,Банки для сыпучих продуктов 4 штуки,Посуда,Ладья,138990 -POSU-0021,Тарелки обеденные фарфоровые 6 штук,Посуда,Ладья,178999 -POSU-0022,Тарелки суповые керамические 4 штуки,Посуда,Ладья,129000 -POSU-0023,Салатник стеклянный 23 см,Посуда,Ладья,68999 -POSU-0024,Кружки керамические 350 мл 4 штуки,Посуда,Ладья,118990 -POSU-0025,Стаканы высокие 400 мл 6 штук,Посуда,Ладья,98990 -POSU-0026,Бокалы для воды 300 мл 6 штук,Посуда,Ладья,149000 -POSU-0027,Термос стальной 1 л,Посуда,Ладья,179000 -POSU-0028,Термокружка с крышкой 450 мл,Посуда,Ладья,99000 -POSU-0029,Сахарница фарфоровая 400 мл,Посуда,Ладья,59000 -POSU-0030,Маслёнка стеклянная с крышкой,Посуда,Ладья,68990 -TECH-0005,Миксер ручной 500 Вт,Бытовая техника,Электрон,248990 -TECH-0006,Измельчитель с чашей 1.5 л,Бытовая техника,Электрон,298990 -TECH-0007,Стационарный блендер 1.8 л,Бытовая техника,Электрон,549000 -TECH-0008,Мясорубка электрическая 1800 Вт,Бытовая техника,Электрон,799000 -TECH-0009,Тостер на 2 ломтика 850 Вт,Бытовая техника,Электрон,299000 -TECH-0010,Вафельница электрическая 1000 Вт,Бытовая техника,Электрон,348999 -TECH-0011,Мультиварка с чашей 5 л,Бытовая техника,Электрон,699000 -TECH-0012,Электроплитка на 1 конфорку 1500 Вт,Бытовая техника,Электрон,249000 -TECH-0013,Мини-печь с грилем 30 л,Бытовая техника,Электрон,899000 -TECH-0014,Микроволновая печь 20 л,Бытовая техника,Электрон,999000 -TECH-0015,Сушилка для овощей 5 поддонов,Бытовая техника,Электрон,499000 -TECH-0016,Кофемолка электрическая 200 Вт,Бытовая техника,Электрон,199000 -TECH-0017,Капельная кофеварка 1.2 л,Бытовая техника,Электрон,399000 -TECH-0018,Соковыжималка шнековая 150 Вт,Бытовая техника,Электрон,799000 -TECH-0019,Утюг паровой 2400 Вт,Бытовая техника,Электрон,349000 -TECH-0020,Швейная машина 12 операций,Бытовая техника,Электрон,1299000 -TECH-0021,Пылесос контейнерный 2000 Вт,Бытовая техника,Электрон,1099000 -TECH-0022,Пылесос вертикальный аккумуляторный,Бытовая техника,Электрон,1499000 -TECH-0023,Электровеник с контейнером 0.5 л,Бытовая техника,Электрон,699000 -TECH-0024,Фен с диффузором 2200 Вт,Бытовая техника,Электрон,299000 -TECH-0025,Щипцы для волос 45 Вт,Бытовая техника,Электрон,249000 -TECH-0026,Машинка для стрижки с 6 насадками,Бытовая техника,Электрон,399000 -TECH-0027,Весы напольные до 180 кг,Бытовая техника,Электрон,198990 -TECH-0028,Весы кухонные до 5 кг,Бытовая техника,Электрон,148990 -TECH-0029,Увлажнитель воздуха 3 л,Бытовая техника,Электрон,499000 -TECH-0030,Тепловентилятор керамический 1500 Вт,Бытовая техника,Электрон,398990 -KIDS-0004,Кроватка кукольная деревянная 45 см,Детские товары,Малышок,248999 -KIDS-0005,Манеж складной 100×100,Детские товары,Малышок,798990 -KIDS-0006,Горшок детский со спинкой,Детские товары,Малышок,79000 -KIDS-0007,Ванночка детская 84 см,Детские товары,Малышок,168990 -KIDS-0008,Подставка детская двухступенчатая,Детские товары,Малышок,89000 -KIDS-0009,Накладка на унитаз с ручками,Детские товары,Малышок,69000 -KIDS-0010,Сиденье для купания на присосках,Детские товары,Малышок,138990 -KIDS-0011,Пелёнки муслиновые 80×100 3 штуки,Детские товары,Малышок,129000 -KIDS-0012,Одеяло для коляски 90×110,Детские товары,Малышок,148990 -KIDS-0013,Нагрудники непромокаемые 5 штук,Детские товары,Малышок,58999 -KIDS-0014,Бутылочка с ручками 250 мл,Детские товары,Малышок,49000 -KIDS-0015,Поильник непроливайка 300 мл,Детские товары,Малышок,54990 -KIDS-0016,Контейнер для пустышки 2 штуки,Детские товары,Малышок,25000 -KIDS-0017,Погремушки мягкие 3 штуки,Детские товары,Малышок,59000 -KIDS-0018,Кубики развивающие 12 штук,Детские товары,Малышок,89000 -KIDS-0019,Пирамидка деревянная 8 колец,Детские товары,Малышок,78990 -KIDS-0020,Сортер с геометрическими фигурами,Детские товары,Малышок,98990 -KIDS-0021,Конструктор деревянный 50 деталей,Детские товары,Малышок,139000 -KIDS-0022,Доска для рисования магнитная 30×40,Детские товары,Малышок,128990 -KIDS-0023,Мольберт детский двусторонний 105 см,Детские товары,Малышок,298999 -KIDS-0024,Набор для лепки 12 цветов,Детские товары,Малышок,69000 -KIDS-0025,Коврик игровой складной 180×150,Детские товары,Малышок,399000 -KIDS-0026,Палатка игровая с окошками,Детские товары,Малышок,199000 -KIDS-0027,Каталка с ручкой и звуком,Детские товары,Малышок,169000 -KIDS-0028,Самокат трёхколёсный до 50 кг,Детские товары,Малышок,298999 -KIDS-0029,Санки со спинкой и ремнём,Детские товары,Малышок,349000 -KIDS-0030,Рюкзак детский объёмом 8 л,Детские товары,Малышок,128990 -WEAR-0004,Футболка хлопковая размер 48,Одежда и обувь,Сатин,78990 -WEAR-0005,Майка хлопковая размер 46,Одежда и обувь,Сатин,58990 -WEAR-0006,Лонгслив трикотажный размер 50,Одежда и обувь,Сатин,108990 -WEAR-0007,Водолазка хлопковая размер 44,Одежда и обувь,Сатин,118990 -WEAR-0008,Толстовка с капюшоном размер 52,Одежда и обувь,Сатин,198999 -WEAR-0009,Брюки спортивные размер 50,Одежда и обувь,Сатин,169000 -WEAR-0010,Легинсы хлопковые размер 46,Одежда и обувь,Сатин,98990 -WEAR-0011,Пижама трикотажная размер 48,Одежда и обувь,Сатин,179000 -WEAR-0012,Ночная сорочка хлопковая размер 50,Одежда и обувь,Сатин,128999 -WEAR-0013,Фартук рабочий с тремя карманами,Одежда и обувь,Сатин,79000 -WEAR-0014,Перчатки хозяйственные 3 пары,Одежда и обувь,Ходики,39000 -WEAR-0015,Варежки вязаные размер 8,Одежда и обувь,Ходики,68990 -WEAR-0016,Шапка вязаная с отворотом размер 56,Одежда и обувь,Ходики,88990 -WEAR-0017,Шарф шерстяной 30×180,Одежда и обувь,Ходики,119000 -WEAR-0018,Ремень текстильный длиной 120 см,Одежда и обувь,Ходики,78990 -WEAR-0019,Стельки войлочные 2 пары,Одежда и обувь,Ходики,38990 -WEAR-0020,Шнурки круглые 120 см 3 пары,Одежда и обувь,Ходики,25000 -WEAR-0021,Кеды текстильные размер 42,Одежда и обувь,Ходики,179000 -WEAR-0022,Кроссовки прогулочные размер 39,Одежда и обувь,Ходики,248990 -WEAR-0023,Ботинки утеплённые размер 41,Одежда и обувь,Ходики,348990 -WEAR-0024,Сапоги резиновые размер 40,Одежда и обувь,Ходики,199000 -WEAR-0025,Полуботинки кожаные размер 43,Одежда и обувь,Ходики,398999 -WEAR-0026,Сандалии с ремешками размер 38,Одежда и обувь,Ходики,189000 -WEAR-0027,Сланцы резиновые размер 41,Одежда и обувь,Ходики,79000 -WEAR-0028,Балетки текстильные размер 37,Одежда и обувь,Ходики,149000 -WEAR-0029,Туфли домашние утеплённые размер 39,Одежда и обувь,Ходики,129000 -WEAR-0030,Галоши садовые размер 42,Одежда и обувь,Ходики,98990 +sku,name,category,brand,price,demand +HOME-0001,Комплект органайзеров для шкафа,Товары для дома,Домовит,129000,обычный +HOME-0002,Сушилка для белья напольная,Товары для дома,Домовит,249900,обычный +HOME-0003,Настольная лампа «Тихий вечер»,Товары для дома,Ладный дом,188990,магнит +HOME-0004,Набор вешалок 12 штук,Товары для дома,Домовит,79000,магнит +TEXT-0001,Комплект постельного белья полутораспальный,Текстиль,Сатин,348990,обычный +TEXT-0002,Плед-покрывало 200×220,Текстиль,Сатин,279000,залёживается +TEXT-0003,Полотенце банное 70×140,Текстиль,Сатин,89000,обычный +TEXT-0004,Штора блэкаут 200×260,Текстиль,Ладный дом,218990,залёживается +POSU-0001,Сковорода 26 см с антипригарным покрытием,Посуда,Кухмастер,198990,залёживается +POSU-0002,Набор кастрюль 3 предмета,Посуда,Кухмастер,458999,магнит +POSU-0003,Чайник заварочный стеклянный 1 л,Посуда,Кухмастер,118990,магнит +POSU-0004,Набор столовых приборов 24 предмета,Посуда,Ладья,289000,магнит +TECH-0001,Электрочайник 1.7 л,Бытовая техника,Электрон,188990,обычный +TECH-0002,Блендер погружной 800 Вт,Бытовая техника,Электрон,279000,залёживается +TECH-0003,Отпариватель ручной,Бытовая техника,Электрон,349000,обычный +TECH-0004,Робот-пылесос,Бытовая техника,Электрон,1899000,залёживается +KIDS-0001,Стульчик для кормления,Детские товары,Малышок,649000,магнит +KIDS-0002,Ночник-проектор «Звёзды»,Детские товары,Малышок,128990,магнит +KIDS-0003,Набор детской посуды 5 предметов,Детские товары,Малышок,88999,обычный +WEAR-0001,Тапочки домашние войлочные,Одежда и обувь,Ходики,98990,обычный +WEAR-0002,Халат махровый,Одежда и обувь,Сатин,249000,магнит +WEAR-0003,Носки хлопковые 5 пар,Одежда и обувь,Ходики,59000,залёживается +HOME-0005,Корзина для белья 45 л,Товары для дома,Домовит,119000,магнит +HOME-0006,Контейнер для хранения 18 л,Товары для дома,Домовит,69000,обычный +HOME-0007,Чехол для одежды 60×100,Товары для дома,Ладный дом,38990,залёживается +HOME-0008,Коврик придверный резиновый 40×60,Товары для дома,Домовит,59000,обычный +HOME-0009,Гладильная доска настольная 73×30,Товары для дома,Ладный дом,149000,залёживается +HOME-0010,Ведро хозяйственное с крышкой 12 л,Товары для дома,Домовит,48990,залёживается +HOME-0011,Швабра с отжимом и ведром 8 л,Товары для дома,Домовит,178990,обычный +HOME-0012,Щётка для пола с черенком 120 см,Товары для дома,Домовит,45000,обычный +HOME-0013,Совок со щёткой на длинной ручке,Товары для дома,Домовит,79000,магнит +HOME-0014,Таз хозяйственный круглый 14 л,Товары для дома,Домовит,38990,магнит +HOME-0015,Этажерка для ванной 3 яруса,Товары для дома,Ладный дом,139000,магнит +HOME-0016,Полка настенная металлическая 45 см,Товары для дома,Ладный дом,99000,обычный +HOME-0017,Зеркало настенное круглое 50 см,Товары для дома,Ладный дом,168990,залёживается +HOME-0018,Набор коробок для обуви 6 штук,Товары для дома,Домовит,129000,магнит +HOME-0019,Подставка для зонтов металлическая,Товары для дома,Ладный дом,158990,обычный +HOME-0020,Крючки самоклеящиеся 8 штук,Товары для дома,Домовит,29000,обычный +HOME-0021,Верёвка бельевая 20 м,Товары для дома,Домовит,19000,обычный +HOME-0022,Прищепки усиленные 24 штуки,Товары для дома,Домовит,24990,залёживается +HOME-0023,Мешки вакуумные для одежды 5 штук,Товары для дома,Домовит,68999,обычный +HOME-0024,Органайзер подвесной с 12 карманами,Товары для дома,Ладный дом,79000,обычный +HOME-0025,Часы настенные круглые 30 см,Товары для дома,Ладный дом,88990,магнит +HOME-0026,Светильник настольный с прищепкой 7 Вт,Товары для дома,Ладный дом,119000,обычный +HOME-0027,Удлинитель бытовой 3 розетки 3 м,Товары для дома,Ладный дом,99000,магнит +HOME-0028,Лестница-стремянка 3 ступени,Товары для дома,Домовит,299000,обычный +HOME-0029,Ящик для инструментов 16 дюймов,Товары для дома,Домовит,138990,обычный +HOME-0030,Набор плечиков для брюк 6 штук,Товары для дома,Домовит,89000,обычный +TEXT-0005,Пододеяльник двуспальный 175×215,Текстиль,Сатин,169000,обычный +TEXT-0006,Простыня на резинке 160×200,Текстиль,Сатин,148990,обычный +TEXT-0007,Наволочки хлопковые 50×70 2 штуки,Текстиль,Сатин,79000,залёживается +TEXT-0008,Одеяло всесезонное 172×205,Текстиль,Сатин,299000,обычный +TEXT-0009,Подушка стёганая 50×70,Текстиль,Сатин,128990,обычный +TEXT-0010,Наматрасник непромокаемый 160×200,Текстиль,Сатин,189000,магнит +TEXT-0011,Покрывало стёганое 180×210,Текстиль,Сатин,238999,обычный +TEXT-0012,Плед флисовый 150×200,Текстиль,Сатин,118990,обычный +TEXT-0013,Полотенце лицевое 50×90,Текстиль,Сатин,49000,обычный +TEXT-0014,Полотенца кухонные 40×60 3 штуки,Текстиль,Сатин,59000,обычный +TEXT-0015,Коврик для ванной хлопковый 50×80,Текстиль,Ладный дом,99000,залёживается +TEXT-0016,Штора для ванной 180×200,Текстиль,Ладный дом,79000,обычный +TEXT-0017,Тюль вуаль 300×260,Текстиль,Ладный дом,199000,обычный +TEXT-0018,Шторы кухонные 150×170 2 полотна,Текстиль,Ладный дом,179000,залёживается +TEXT-0019,Скатерть водоотталкивающая 140×180,Текстиль,Ладный дом,129000,обычный +TEXT-0020,Дорожка на стол хлопковая 40×140,Текстиль,Ладный дом,59000,залёживается +TEXT-0021,Салфетки столовые льняные 4 штуки,Текстиль,Сатин,79000,обычный +TEXT-0022,Чехлы на табурет 35×35 4 штуки,Текстиль,Ладный дом,108990,обычный +TEXT-0023,Подушка декоративная 40×40,Текстиль,Сатин,68999,магнит +TEXT-0024,Чехол для диванной подушки 45×45,Текстиль,Сатин,39000,обычный +TEXT-0025,Фартук кухонный с карманом,Текстиль,Ладный дом,59000,магнит +TEXT-0026,Прихватки стёганые 20×20 2 штуки,Текстиль,Ладный дом,38990,магнит +TEXT-0027,Халат вафельный размер 48–50,Текстиль,Сатин,199000,залёживается +TEXT-0028,Комплект полотенец 50×90 и 70×140,Текстиль,Сатин,138999,обычный +TEXT-0029,Одеяло детское байковое 100×140,Текстиль,Сатин,169000,обычный +TEXT-0030,Подушка для стула 40×40,Текстиль,Ладный дом,59000,обычный +POSU-0005,Кастрюля эмалированная 3 л,Посуда,Кухмастер,168990,обычный +POSU-0006,Ковш нержавеющий с крышкой 1.5 л,Посуда,Кухмастер,138990,залёживается +POSU-0007,Сотейник литой с крышкой 28 см,Посуда,Кухмастер,269000,магнит +POSU-0008,Сковорода блинная 22 см,Посуда,Кухмастер,149000,обычный +POSU-0009,Казан алюминиевый 6 л,Посуда,Кухмастер,299000,обычный +POSU-0010,Форма для запекания стеклянная 2.4 л,Посуда,Кухмастер,118990,магнит +POSU-0011,Противень стальной 37×27,Посуда,Кухмастер,79000,залёживается +POSU-0012,Набор форм для выпечки 3 штуки,Посуда,Кухмастер,139000,залёживается +POSU-0013,Миска нержавеющая 24 см,Посуда,Кухмастер,68990,залёживается +POSU-0014,Дуршлаг стальной с ручками 22 см,Посуда,Кухмастер,89000,залёживается +POSU-0015,Тёрка четырёхгранная 23 см,Посуда,Кухмастер,49000,обычный +POSU-0016,Доска разделочная бамбуковая 35×25,Посуда,Кухмастер,78990,магнит +POSU-0017,Набор ножей с подставкой 6 предметов,Посуда,Кухмастер,248990,обычный +POSU-0018,Половник и шумовка 2 предмета,Посуда,Кухмастер,58990,обычный +POSU-0019,Контейнеры пищевые 7 предметов,Посуда,Кухмастер,99000,залёживается +POSU-0020,Банки для сыпучих продуктов 4 штуки,Посуда,Ладья,138990,залёживается +POSU-0021,Тарелки обеденные фарфоровые 6 штук,Посуда,Ладья,178999,обычный +POSU-0022,Тарелки суповые керамические 4 штуки,Посуда,Ладья,129000,обычный +POSU-0023,Салатник стеклянный 23 см,Посуда,Ладья,68999,обычный +POSU-0024,Кружки керамические 350 мл 4 штуки,Посуда,Ладья,118990,обычный +POSU-0025,Стаканы высокие 400 мл 6 штук,Посуда,Ладья,98990,обычный +POSU-0026,Бокалы для воды 300 мл 6 штук,Посуда,Ладья,149000,обычный +POSU-0027,Термос стальной 1 л,Посуда,Ладья,179000,залёживается +POSU-0028,Термокружка с крышкой 450 мл,Посуда,Ладья,99000,залёживается +POSU-0029,Сахарница фарфоровая 400 мл,Посуда,Ладья,59000,магнит +POSU-0030,Маслёнка стеклянная с крышкой,Посуда,Ладья,68990,обычный +TECH-0005,Миксер ручной 500 Вт,Бытовая техника,Электрон,248990,обычный +TECH-0006,Измельчитель с чашей 1.5 л,Бытовая техника,Электрон,298990,обычный +TECH-0007,Стационарный блендер 1.8 л,Бытовая техника,Электрон,549000,магнит +TECH-0008,Мясорубка электрическая 1800 Вт,Бытовая техника,Электрон,799000,магнит +TECH-0009,Тостер на 2 ломтика 850 Вт,Бытовая техника,Электрон,299000,залёживается +TECH-0010,Вафельница электрическая 1000 Вт,Бытовая техника,Электрон,348999,обычный +TECH-0011,Мультиварка с чашей 5 л,Бытовая техника,Электрон,699000,магнит +TECH-0012,Электроплитка на 1 конфорку 1500 Вт,Бытовая техника,Электрон,249000,обычный +TECH-0013,Мини-печь с грилем 30 л,Бытовая техника,Электрон,899000,залёживается +TECH-0014,Микроволновая печь 20 л,Бытовая техника,Электрон,999000,обычный +TECH-0015,Сушилка для овощей 5 поддонов,Бытовая техника,Электрон,499000,обычный +TECH-0016,Кофемолка электрическая 200 Вт,Бытовая техника,Электрон,199000,обычный +TECH-0017,Капельная кофеварка 1.2 л,Бытовая техника,Электрон,399000,залёживается +TECH-0018,Соковыжималка шнековая 150 Вт,Бытовая техника,Электрон,799000,залёживается +TECH-0019,Утюг паровой 2400 Вт,Бытовая техника,Электрон,349000,обычный +TECH-0020,Швейная машина 12 операций,Бытовая техника,Электрон,1299000,обычный +TECH-0021,Пылесос контейнерный 2000 Вт,Бытовая техника,Электрон,1099000,залёживается +TECH-0022,Пылесос вертикальный аккумуляторный,Бытовая техника,Электрон,1499000,магнит +TECH-0023,Электровеник с контейнером 0.5 л,Бытовая техника,Электрон,699000,магнит +TECH-0024,Фен с диффузором 2200 Вт,Бытовая техника,Электрон,299000,обычный +TECH-0025,Щипцы для волос 45 Вт,Бытовая техника,Электрон,249000,обычный +TECH-0026,Машинка для стрижки с 6 насадками,Бытовая техника,Электрон,399000,залёживается +TECH-0027,Весы напольные до 180 кг,Бытовая техника,Электрон,198990,залёживается +TECH-0028,Весы кухонные до 5 кг,Бытовая техника,Электрон,148990,обычный +TECH-0029,Увлажнитель воздуха 3 л,Бытовая техника,Электрон,499000,обычный +TECH-0030,Тепловентилятор керамический 1500 Вт,Бытовая техника,Электрон,398990,магнит +KIDS-0004,Кроватка кукольная деревянная 45 см,Детские товары,Малышок,248999,обычный +KIDS-0005,Манеж складной 100×100,Детские товары,Малышок,798990,залёживается +KIDS-0006,Горшок детский со спинкой,Детские товары,Малышок,79000,магнит +KIDS-0007,Ванночка детская 84 см,Детские товары,Малышок,168990,обычный +KIDS-0008,Подставка детская двухступенчатая,Детские товары,Малышок,89000,залёживается +KIDS-0009,Накладка на унитаз с ручками,Детские товары,Малышок,69000,обычный +KIDS-0010,Сиденье для купания на присосках,Детские товары,Малышок,138990,залёживается +KIDS-0011,Пелёнки муслиновые 80×100 3 штуки,Детские товары,Малышок,129000,обычный +KIDS-0012,Одеяло для коляски 90×110,Детские товары,Малышок,148990,магнит +KIDS-0013,Нагрудники непромокаемые 5 штук,Детские товары,Малышок,58999,обычный +KIDS-0014,Бутылочка с ручками 250 мл,Детские товары,Малышок,49000,обычный +KIDS-0015,Поильник непроливайка 300 мл,Детские товары,Малышок,54990,залёживается +KIDS-0016,Контейнер для пустышки 2 штуки,Детские товары,Малышок,25000,магнит +KIDS-0017,Погремушки мягкие 3 штуки,Детские товары,Малышок,59000,магнит +KIDS-0018,Кубики развивающие 12 штук,Детские товары,Малышок,89000,залёживается +KIDS-0019,Пирамидка деревянная 8 колец,Детские товары,Малышок,78990,обычный +KIDS-0020,Сортер с геометрическими фигурами,Детские товары,Малышок,98990,обычный +KIDS-0021,Конструктор деревянный 50 деталей,Детские товары,Малышок,139000,обычный +KIDS-0022,Доска для рисования магнитная 30×40,Детские товары,Малышок,128990,обычный +KIDS-0023,Мольберт детский двусторонний 105 см,Детские товары,Малышок,298999,залёживается +KIDS-0024,Набор для лепки 12 цветов,Детские товары,Малышок,69000,обычный +KIDS-0025,Коврик игровой складной 180×150,Детские товары,Малышок,399000,обычный +KIDS-0026,Палатка игровая с окошками,Детские товары,Малышок,199000,обычный +KIDS-0027,Каталка с ручкой и звуком,Детские товары,Малышок,169000,залёживается +KIDS-0028,Самокат трёхколёсный до 50 кг,Детские товары,Малышок,298999,обычный +KIDS-0029,Санки со спинкой и ремнём,Детские товары,Малышок,349000,залёживается +KIDS-0030,Рюкзак детский объёмом 8 л,Детские товары,Малышок,128990,обычный +WEAR-0004,Футболка хлопковая размер 48,Одежда и обувь,Сатин,78990,залёживается +WEAR-0005,Майка хлопковая размер 46,Одежда и обувь,Сатин,58990,обычный +WEAR-0006,Лонгслив трикотажный размер 50,Одежда и обувь,Сатин,108990,магнит +WEAR-0007,Водолазка хлопковая размер 44,Одежда и обувь,Сатин,118990,обычный +WEAR-0008,Толстовка с капюшоном размер 52,Одежда и обувь,Сатин,198999,обычный +WEAR-0009,Брюки спортивные размер 50,Одежда и обувь,Сатин,169000,залёживается +WEAR-0010,Легинсы хлопковые размер 46,Одежда и обувь,Сатин,98990,залёживается +WEAR-0011,Пижама трикотажная размер 48,Одежда и обувь,Сатин,179000,обычный +WEAR-0012,Ночная сорочка хлопковая размер 50,Одежда и обувь,Сатин,128999,обычный +WEAR-0013,Фартук рабочий с тремя карманами,Одежда и обувь,Сатин,79000,залёживается +WEAR-0014,Перчатки хозяйственные 3 пары,Одежда и обувь,Ходики,39000,обычный +WEAR-0015,Варежки вязаные размер 8,Одежда и обувь,Ходики,68990,обычный +WEAR-0016,Шапка вязаная с отворотом размер 56,Одежда и обувь,Ходики,88990,магнит +WEAR-0017,Шарф шерстяной 30×180,Одежда и обувь,Ходики,119000,обычный +WEAR-0018,Ремень текстильный длиной 120 см,Одежда и обувь,Ходики,78990,обычный +WEAR-0019,Стельки войлочные 2 пары,Одежда и обувь,Ходики,38990,залёживается +WEAR-0020,Шнурки круглые 120 см 3 пары,Одежда и обувь,Ходики,25000,обычный +WEAR-0021,Кеды текстильные размер 42,Одежда и обувь,Ходики,179000,обычный +WEAR-0022,Кроссовки прогулочные размер 39,Одежда и обувь,Ходики,248990,обычный +WEAR-0023,Ботинки утеплённые размер 41,Одежда и обувь,Ходики,348990,залёживается +WEAR-0024,Сапоги резиновые размер 40,Одежда и обувь,Ходики,199000,обычный +WEAR-0025,Полуботинки кожаные размер 43,Одежда и обувь,Ходики,398999,залёживается +WEAR-0026,Сандалии с ремешками размер 38,Одежда и обувь,Ходики,189000,обычный +WEAR-0027,Сланцы резиновые размер 41,Одежда и обувь,Ходики,79000,обычный +WEAR-0028,Балетки текстильные размер 37,Одежда и обувь,Ходики,149000,залёживается +WEAR-0029,Туфли домашние утеплённые размер 39,Одежда и обувь,Ходики,129000,магнит +WEAR-0030,Галоши садовые размер 42,Одежда и обувь,Ходики,98990,обычный diff --git a/docs/specs/2026-07-30-stand-v2-realism.md b/docs/specs/2026-07-30-stand-v2-realism.md index 4ef87ef..cc0299b 100644 --- a/docs/specs/2026-07-30-stand-v2-realism.md +++ b/docs/specs/2026-07-30-stand-v2-realism.md @@ -223,10 +223,12 @@ smoke — сравнение `system.columns` поднятого стенда с ## 3. Каталог товаров CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `category`, -`brand`, `price`) — **словарь ClickHouse** из файла. Тот же файл использует -генератор — расхождений нет по построению. Даёт `dictGet` в витринах и -разговор о политике обновления словаря. На кластере файл монтируется в обе -ноды, словарь создаётся ON CLUSTER. +`brand`, `price`, `demand`) — **словарь ClickHouse** из файла. Тот же файл +использует генератор — расхождений нет по построению. Даёт `dictGet` в +витринах и разговор о политике обновления словаря. На кластере файл +монтируется в обе ноды, словарь создаётся ON CLUSTER. Последняя колонка — +уровень спроса товара, заведена при исполнении #50 (спека генератора, +раздел 9): генератор решает по ней, что уходит из карточки в корзину. ## 4. Сверка `purchase` против заказов diff --git a/docs/specs/2026-08-01-generator.md b/docs/specs/2026-08-01-generator.md index 8e53cd8..5ac2dd3 100644 --- a/docs/specs/2026-08-01-generator.md +++ b/docs/specs/2026-08-01-generator.md @@ -754,14 +754,90 @@ pytest-тест с маркером `perf` и таймаутом-обрубан склонность из цены* — жёсткая связь, в которой менти найдёт ровно то, что мы в неё вложили, и ничего сверх. - **Границы правки — те же вилки раздела 5**: аудитория 6–8 тыс., средний - день около 50 тыс. событий (сейчас 49 509 — запас невелик, и рост - событий корзины считается против него), конверсия визита около 2% - (сейчас 2,43%). Заказ рисуется из корзины, а корзина сузилась, поэтому - доля брошенных позиций (число мира — 15%, замер по покупающим корзинам — - 12%) и состав заказа (1,87 позиции) перемеряются вместе с - остальным: сохраняется не число, а свойство — заказ уже корзины и - однопозиционных заказов около половины. Числа канонического мира после - правки перемеряются и переписываются. + день около 50 тыс. событий, конверсия визита около 2%. Заказ рисуется из + корзины, а корзина сузилась, поэтому доля брошенных позиций и состав + заказа перемеряются вместе с остальным: сохраняется не число, а + свойство — заказ уже корзины и однопозиционных заказов около половины. + +**Измерено после правки #50** (канонический мир, 14 дней; правый столбец — +то же до правки). Доли по уровням и состав заказа считаются по окну снимка +целиком, а не как среднее дневных долей: заказов в дне около 240, и на одном +дне такая доля гуляет на несколько пунктов от одной случайности выборки. +Трафиковая половина не сдвинулась ни на строку: набор просмотров страниц +сверен со снимком, снятым до первой правки. + +| величина | стало | было | +|---|---|---| +| дневная аудитория | 6 637–7 595, средняя 7 166 | без изменений | +| визитов в день | 9 879 | без изменений | +| событий в день, средних | 49 834 | 49 509 | +| просмотров карточек в день | 25 067 | без изменений | +| событий корзины в день | 2 161, в 1 306 визитах | 1 836, в 765 визитах | +| из них визиты без страницы `/cart` | 541 — 41,4% | 0 | +| визитов со страницей `/cart` без позиции | 0 | 0 | +| просмотров карточек, дошедших до корзины | 8,62% | 7,32% | +| конверсия карточки в корзину: магнит / обычный / залёживается | 13,1% / 9,2% / 5,8% | 7,7% у всех | +| событий корзины вне `/cart`: магнит / обычный / залёживается | 42,0% / 20,2% / 17,1% | 0 у всех | +| конверсия визита в покупку | 2,43% | без изменений | +| позиций на заказ | 1,74; однопозиционных 56,4% | 1,87; 50,3% | +| брошено позиций в покупающих корзинах | 7,25% | 12,0% | + +Знаменатель «просмотров карточек» здесь — только просмотры страниц. При +постановке #50 в него попали и строки событий корзины: событие садится на +карточку и несёт её же страницу, отчего вышло 26 903 просмотра и 6,82% +вместо 25 067 и 7,32%. Величина одна и та же, счёт разный. + +Решено при исполнении #50 самой реализацией: + +- **Намерение визита берётся из шага воронки, а не из метки покупателя.** + Кладёт всякий визит, открывший карточку, но дошедший до `/cart` кладёт + почти всё, что сравнивал, а прочий — редко. Метка покупателя в это + решение не заходит, и не потому, что не влияет: она уже влияет — через + воронку, где помеченному дано 18% против 6%. Взять её вторым слагаемым + значило бы посчитать одно и то же дважды и протянуть план состава сквозь + шов, по которому день отдаёт торговой половине только страницы и товары. + Плата названа: у визита без намерения добавление ни с чем в человеке не + связано — упрощение, но не ложь, потому что склонные покупать и так + преобладают среди дошедших до `/cart`. +- **Два ряда вероятностей, а не ряд с множителем.** Уровень спроса читается + дважды: ряд для визита с намерением и ряд для того, кто смотрит. Один ряд + с множителем намерения не годится: вероятности визита с намерением обязаны + быть высокими (иначе его корзина схлопывается до одной позиции, а заказ до + одного товара), а у потолка в 100% разница уровней съедается, и конверсия + по уровням перестаёт различаться. Разрыв уровней внутри рядов поэтому + разный: у смотрящего товар решает почти всё, у пришедшего покупать — + заслонён намерением. +- **Доля брошенных позиций опущена с 15% до 10%.** Корзина сузилась с 2,4 + позиции до 1,7, и прежние 15% чаще выносили её целиком: спасённая позиция + возвращала заказ к одному товару, и однопозиционных заказов набиралось под + 60%. Свойство сохранено, число перемерено — ровно так, как этот блок и + оговаривал. +- **Ни одному уровню спроса нельзя дать нулевую вероятность.** Ноль у + залежавшегося товара выглядел заманчиво — он дешевле всего разводил + конверсию уровней, — но по товарам этого уровня возвращал ровно то + равенство, ради которого правка и делалась: событие корзины случалось бы + только внутри визита, открывшего `/cart`, без единого исключения на + четверти каталога. Отвязка либо про весь ассортимент, либо она не отвязка. + Разводить уровни поэтому приходится не нулём внизу второго ряда, а + ослаблением залежавшегося в первом: пришедший покупать берёт его реже + ходового. +- **Уровень спроса — колонка `demand` в конце файла каталога**, значения + словами («магнит», «обычный», «залёживается»). Раздан он одной колодой на + весь каталог: доли по категориям расходятся сами (магнитов от четырёх до + девяти на тридцать строк). Раскладывать колоду поровну в каждую категорию + было бы той же неправдоподобной ровностью, какую тикет вычищал из данных. + Связь с ценой отклонена выше, и тест сторожит её отсутствие — по четвертям + цены и по медиане, потому что одни только края списка обманываются + четырьмя исключениями. Хвост соседям: словарь ClickHouse над этим файлом + ещё не построен (#37/#43) — колонку надо взять в его описание сразу, а не + догонять правкой; манифесту нужен хеш каталога (хвост #42) тем более, + потому что теперь в файле живёт ещё и поведение. +- **Доля просмотров карточек, доходящих до корзины, выросла с 7,3% до 8,6% + и подошла к потолку вилки.** Иначе не сошлось: треть кладущих визитов вне + `/cart` требует своих позиций, а состав заказа требует, чтобы визит с + намерением клал почти всё сравненное. Обе границы тянут число вверх, и + опустить его — значит отпустить одну из них. Место до 9% невелико, и + следующая правка торговой половины считается против него. Остаётся открытым, за тикетами: diff --git a/generator/src/clickstream_generator/catalog.py b/generator/src/clickstream_generator/catalog.py index f54d020..bf84c83 100644 --- a/generator/src/clickstream_generator/catalog.py +++ b/generator/src/clickstream_generator/catalog.py @@ -10,16 +10,22 @@ раз в неделю, корзина в двадцать позиций. Что решено формой файла, а не его длиной: колонки `sku,name,category,brand, -price`; артикул — четыре латинские буквы категории, дефис и четыре цифры; -цена — целые копейки (деньги генератор считает целыми, спека, раздел 2). -Часть цен кратна рублю, часть несёт копейки — как в обычной рознице -(1 289,90 ₽). Без копеек урок про Float64 был бы беспредметным: округлять -нечего, и разрыв между `productPrice` и `purchaseRevenue` пришлось бы -выдумывать (спека генератора, раздел 9). +price,demand`; артикул — четыре латинские буквы категории, дефис и четыре +цифры; цена — целые копейки (деньги генератор считает целыми, спека, +раздел 2). Часть цен кратна рублю, часть несёт копейки — как в обычной +рознице (1 289,90 ₽). Без копеек урок про Float64 был бы беспредметным: +округлять нечего, и разрыв между `productPrice` и `purchaseRevenue` +пришлось бы выдумывать (спека генератора, раздел 9). Строк в файле может быть сколько угодно: ни генератор, ни тесты их не считают, а товар для карточки выбирается равномерно внутри категории. -Популярность товаров не моделируется — придумывать вес каждой строке -пришлось бы вручную, а каталог растёт механически. + +**Уровень спроса** — привлекательность товара, а не частота его показа: +карточку любого товара открывают одинаково часто, но магнит из открытой +карточки уходит в корзину чаще, чем залежавшийся. Уровень приписан строке +в файле — он постоянная часть мира, а не бросок дня, — и приписан россыпью: +ни с ценой, ни с категорией он не связан. Связь с ценой была бы жёсткой, и +менти нашёл бы в данных ровно то, что мы в них вложили (спека генератора, +раздел 9). Сами вероятности живут в числах мира, здесь только имена уровней. """ import csv @@ -34,7 +40,11 @@ from numpy.typing import NDArray # Каталог не настраивается извне — он часть мира, а не запуска. CATALOG_PATH = Path(__file__).resolve().parents[3] / "data" / "catalog" / "products.csv" -COLUMNS = ("sku", "name", "category", "brand", "price") +COLUMNS = ("sku", "name", "category", "brand", "price", "demand") + +# Уровни спроса по убыванию желанности: порядок здесь — порядок чисел мира, +# которыми уровень превращается в вероятность (`world.SHOPPING_ADD_PERCENT`). +DEMAND_LEVELS = ("магнит", "обычный", "залёживается") @dataclass(frozen=True, slots=True) @@ -70,6 +80,7 @@ class Catalog: category: NDArray[np.int64] brand: NDArray[np.object_] price: NDArray[np.int64] + demand: NDArray[np.int64] grouped: NDArray[np.int64] first: NDArray[np.int64] count: NDArray[np.int64] @@ -85,12 +96,14 @@ def catalog() -> Catalog: category = np.array([index[row["category"]] for row in rows], dtype=np.int64) grouped = np.argsort(category, kind="stable") count = np.bincount(category, minlength=len(CATEGORIES)) + level = {name: number for number, name in enumerate(DEMAND_LEVELS)} return Catalog( sku=np.array([row["sku"] for row in rows], dtype=object), name=np.array([row["name"] for row in rows], dtype=object), category=category, brand=np.array([row["brand"] for row in rows], dtype=object), price=np.array([int(row["price"]) for row in rows], dtype=np.int64), + demand=np.array([level[row["demand"]] for row in rows], dtype=np.int64), grouped=grouped, first=np.concatenate(([0], np.cumsum(count)[:-1])), count=count, diff --git a/generator/src/clickstream_generator/commerce.py b/generator/src/clickstream_generator/commerce.py index b773d90..e6841dc 100644 --- a/generator/src/clickstream_generator/commerce.py +++ b/generator/src/clickstream_generator/commerce.py @@ -8,12 +8,18 @@ граница модельных суток режет всё, что за неё вышло: визит, у которого подтверждение срезано полуночью, покупки не даёт — заказа не было. -**Корзина шире заказа.** Посетитель кладёт товары тех карточек, которые -открывал в этом визите; карточка, открытая дважды, даёт одну позицию — -повторный просмотр это раздумье, а не второй товар. Покупает он не всё: -часть позиций остаётся брошенной. Иначе событие корзины не рассказывало бы -ничего сверх покупки — заказ был бы её точной копией, и сравнивать было бы -нечего. +**Кладёт любой визит, открывший карточку.** Положить и корзину не открыть — +самый массовый сюжет магазина, поэтому событие корзины не привязано к +странице `/cart`: иначе факт добавления идеально предсказывался бы более +поздним просмотром страницы, а такого равенства в живых данных не бывает. +Решается каждая открытая карточка отдельно, а не визит целиком, и решают её +двое: намерение визита и уровень спроса товара (числа мира). Карточка, +открытая дважды, даёт одну позицию — повторный просмотр это раздумье, а не +второй товар. + +**Корзина шире заказа.** Покупает посетитель не всё, что положил: часть +позиций остаётся брошенной. Иначе событие корзины не рассказывало бы ничего +сверх покупки — заказ был бы её точной копией, и сравнивать было бы нечего. **Деньги считаются целыми копейками.** В колонку `productPrice` ложатся целые рубли, как у Метрики, а дробное число в событии одно — @@ -145,7 +151,7 @@ def weave( события встают между ними, и поток пересобирается одним порядком. """ rng = day_stream(seed, day, Component.COMMERCE) - baskets = _baskets(page, product, columns["VisitID"]) + baskets = _baskets(rng, page, product, columns["VisitID"]) if not len(baskets): return columns, page, product @@ -172,13 +178,24 @@ def _draws(rng: np.random.Generator, baskets: _Baskets) -> _Draws: def _baskets( - page: NDArray[np.uint8], product: NDArray[np.int64], visit: NDArray[np.uint64] + rng: np.random.Generator, + page: NDArray[np.uint8], + product: NDArray[np.int64], + visit: NDArray[np.uint64], ) -> _Baskets: """Что посетитель положил в корзину и дошёл ли до заказа. - Корзина есть у визита, дошедшего до страницы корзины; в ней товары всех - карточек этого визита. Карточка, открытая дважды, даёт одну позицию — - остаётся первая: тогда товар и кладут. + Корзина есть у всякого визита, положившего хоть что-то, — не только у + дошедшего до страницы `/cart`. Кандидаты — карточки, открытые в визите, + по одной на товар: карточка, открытая дважды, даёт одну позицию, и + остаётся первая — тогда товар и кладут. + + Намерение визита берётся из шага воронки: тот, кто дошёл до страницы + корзины, пришёл покупать. Позиция у него есть всегда — страница корзины + при пустой корзине невозможна, — и держится это высокой вероятностью, а + не принуждением: спасать приходится считаные проценты таких визитов. + Принуждай мы всех, корзина схлопнулась бы до одной позиции, а заказ до + одного товара. """ order = np.argsort(visit, kind="stable") page, product, visit = page[order], product[order], visit[order] @@ -187,54 +204,103 @@ def _baskets( # упорядоченным, а сортировка по визиту устойчивая. started = np.concatenate(([True], visit[1:] != visit[:-1])) number = np.cumsum(started) - 1 + visits = int(started.sum()) - with_cart = number[page == Page.CART] - basket_of_visit = np.full(int(started.sum()), -1, dtype=np.int64) - basket_of_visit[with_cart] = np.arange(with_cart.size) - - cards = np.flatnonzero((page == Page.PRODUCT) & (basket_of_visit[number] >= 0)) + goods = catalog.catalog() + cards = np.flatnonzero(page == Page.PRODUCT) # Ключ «визит и товар»: `np.unique` отдаёт индексы первых вхождений, - # поэтому вторая карточка того же товара позиции не добавляет. - key = number[cards] * catalog.catalog().sku.size + product[cards] - positions = np.sort(cards[np.unique(key, return_index=True)[1]]) + # поэтому вторая карточка того же товара кандидата не добавляет. + key = number[cards] * goods.sku.size + product[cards] + opened = np.sort(cards[np.unique(key, return_index=True)[1]]) + holder = number[opened] - # Подтверждение без корзины невозможно: полночь режет визит с хвоста, а - # корзина в нём раньше подтверждения — поэтому у каждого подтверждения - # корзина есть, и номер её всегда найдётся. + shopping = np.zeros(visits, dtype=np.bool_) + shopping[number[page == Page.CART]] = True + taken = _taken(rng, goods.demand[product[opened]], shopping[holder]) + # Кандидаты визита лежат подряд, визиты идут по порядку — поэтому начало + # каждого куска находится накопленной суммой. + opened_count = np.bincount(holder, minlength=visits) + opened_first = np.cumsum(opened_count) - opened_count + empty = shopping & (np.bincount(holder[taken], minlength=visits) == 0) + _at_least_one(rng, taken, opened_first, opened_count, empty) + + positions, owner = opened[taken], holder[taken] + with_basket = np.unique(owner) + basket_of_visit = np.full(visits, -1, dtype=np.int64) + basket_of_visit[with_basket] = np.arange(with_basket.size) + + # Подтверждение без корзины невозможно: до него визит прошёл страницу + # корзины, а у неё позиция есть всегда — поэтому номер корзины найдётся. confirmed = np.flatnonzero(page == Page.CONFIRMATION) - confirmation = np.full(with_cart.size, -1, dtype=np.int64) + confirmation = np.full(with_basket.size, -1, dtype=np.int64) confirmation[basket_of_visit[number[confirmed]]] = order[confirmed] - basket = basket_of_visit[number[positions]] - count = np.bincount(basket, minlength=with_cart.size) + basket = basket_of_visit[owner] + count = np.bincount(basket, minlength=with_basket.size) return _Baskets( anchor=order[positions], product=product[positions], basket=basket, - # Позиции лежат подряд, корзины идут по порядку визитов — поэтому - # начало каждого куска находится накопленной суммой. + # Позиции лежат подряд, корзины идут по порядку визитов — начало + # каждого куска находится той же накопленной суммой. first=np.cumsum(count) - count, count=count, confirmation=confirmation, ) +def _taken( + rng: np.random.Generator, + demand: NDArray[np.int64], + shopping: NDArray[np.bool_], +) -> NDArray[np.bool_]: + """Дошла ли открытая карточка до корзины: решают намерение и товар. + + Два ряда чисел мира по уровням спроса: один для визита, пришедшего + покупать, другой для того, кто просто смотрит. Товар решает у обоих, но + у смотрящего он решает почти всё — потому конверсия «карточка → корзина» + по уровням спроса и становится различимой. + """ + chance = np.where( + shopping, + np.array(world.SHOPPING_ADD_PERCENT)[demand], + np.array(world.BROWSING_ADD_PERCENT)[demand], + ) + return rng.integers(0, 100, demand.size) < chance + + def _kept(rng: np.random.Generator, baskets: _Baskets) -> NDArray[np.bool_]: """Какие позиции корзины дошли до заказа: часть остаётся брошенной. - Пустым заказ не бывает: если брошены все позиции, одна возвращается — - какая, решает свой бросок. Хотя бы одна позиция у корзины есть всегда: - перед корзиной визит обязательно открывал карточку. + Пустым заказ не бывает: если брошены все позиции, одна возвращается. """ kept = ( rng.integers(0, 100, baskets.product.size) >= world.ABANDONED_POSITION_PERCENT ) - rescued = baskets.first + rng.integers(0, baskets.count) empty = np.bincount(baskets.basket[kept], minlength=len(baskets)) == 0 - kept[rescued[empty]] = True + _at_least_one(rng, kept, baskets.first, baskets.count, empty) return kept +def _at_least_one( + rng: np.random.Generator, + chosen: NDArray[np.bool_], + first: NDArray[np.int64], + count: NDArray[np.int64], + empty: NDArray[np.bool_], +) -> None: + """Возвращает один выбор тем группам, у которых бросок унёс все. + + Приём общий у двух мест: у визита выбираются карточки, у корзины — + позиции, а правило одно — пустой ни та, ни другая быть не может. Какой + выбор вернуть, решает свой бросок: брать первый значило бы, что ранняя + карточка визита переживает любой бросок. Группа без членов сюда не + приходит: у визита со страницей корзины карточка была, у корзины — + позиция. + """ + chosen[first[empty] + rng.integers(0, count[empty])] = True + + def _coupons(rng: np.random.Generator, baskets: int) -> NDArray[np.int64]: """Промокод корзины: номер строки в таблице кодов или −1, если кода нет.""" code = rng.integers(0, len(world.COUPONS), baskets) diff --git a/generator/src/clickstream_generator/day.py b/generator/src/clickstream_generator/day.py index 384d3ba..59df10f 100644 --- a/generator/src/clickstream_generator/day.py +++ b/generator/src/clickstream_generator/day.py @@ -258,8 +258,9 @@ def _walk( goods = catalog.catalog() shown = page == Page.PRODUCT row_category = np.repeat(visits.category, visits.pages)[shown] - # Товар — равномерно внутри категории визита: популярность строк не - # моделируется, каталог дорастает механически (см. `catalog`). + # Товар — равномерно внутри категории визита: карточки всех товаров + # открывают одинаково часто, а различает их уровень спроса — уже в + # корзине, а не в показе (см. `catalog`). inside = rng.integers(0, goods.count[row_category]) product = np.full(total, -1, dtype=np.int64) product[shown] = goods.grouped[goods.first[row_category] + inside] diff --git a/generator/src/clickstream_generator/world.py b/generator/src/clickstream_generator/world.py index 5b0e995..1ade6fb 100644 --- a/generator/src/clickstream_generator/world.py +++ b/generator/src/clickstream_generator/world.py @@ -160,11 +160,40 @@ BUYER_CHECKOUT_OF_CART_PERCENT = 60 # страницу, на которой посетитель его нажал. TRADE_DELAY_SECONDS = (2, 8) +# Кто и что кладёт в корзину: доля открытых карточек, которая до неё дошла. +# Оба ряда читаются по уровням спроса товара (`catalog.DEMAND_LEVELS`): +# магнит, обычный, залёживается. +# +# Первый ряд — визит, дошедший до страницы корзины. Он пришёл покупать и +# кладёт почти всё, что сравнивал; уровень решает, что именно из сравненного +# он выберет, и залежавшийся товар проигрывает соседям по вкладке. Верх ряда +# высок намеренно: опусти его — и корзина такого визита схлопнется до одной +# позиции, а заказ до одного товара. +# +# Второй ряд — визит, который до страницы корзины не дошёл. Кладёт он редко, +# и магнит уходит у него из карточки втрое чаще обычного товара и вшестеро +# чаще залежавшегося: случайного посетителя решает соблазнить сам товар, +# больше решать нечему. Событий при этом выходит почти поровну — магнитов +# 47% таких добавлений, обычных 42%: обычных в каталоге вдвое с лишним +# больше, и разница уровней живёт в доле открытых карточек, а не в счёте +# событий. Нулю в этом ряду места нет: обнулить уровень значило бы вернуть +# по его товарам ровно то равенство «положил — открыл корзину», ради +# которого правка и делалась. +# +# Спрос растянут во втором ряду сильнее, чем в первом, и это не подгонка: +# намерение заслоняет товар, поэтому привлекательность видна там, где +# намерения нет. +SHOPPING_ADD_PERCENT = (98, 94, 55) +BROWSING_ADD_PERCENT = (6, 2, 1) + # Доля позиций корзины, которые остались брошенными: заказ уже корзины. # Иначе событие корзины не рассказывало бы ничего сверх покупки — заказ был # бы её точной копией, и сравнивать было бы нечего. Пустым заказ не бывает: # если брошены все позиции, одна остаётся (спека генератора, раздел 9). -ABANDONED_POSITION_PERCENT = 15 +# Число опущено с 15% вместе с отвязкой корзины от страницы корзины: корзина +# стала уже (1,7 позиции вместо 2,4), и прежние 15% чаще выносили её целиком, +# а спасённая позиция возвращала заказ к одному товару. +ABANDONED_POSITION_PERCENT = 10 # Сколько штук одного товара берут: веса для 1, 2, 3 штук. Обычно одна, # изредка две-три — непродовольственная розница. diff --git a/generator/tests/test_catalog.py b/generator/tests/test_catalog.py index fb20d9e..b7c17b6 100644 --- a/generator/tests/test_catalog.py +++ b/generator/tests/test_catalog.py @@ -6,8 +6,10 @@ целая цена. """ +import collections import csv import re +import statistics import numpy as np @@ -78,6 +80,96 @@ def test_categories_are_told_apart_by_prefix_and_by_address(): assert len(prefixes) == len(slugs) == len(catalog.CATEGORIES) +def test_every_row_carries_a_known_demand_level(): + """Уровень спроса есть у каждой строки, и уровней ровно три.""" + known = set(catalog.DEMAND_LEVELS) + by_category: dict[str, set[str]] = {} + for row in rows(): + by_category.setdefault(row["category"], set()).add(row["demand"]) + assert set().union(*by_category.values()) == known + # Уровни рассыпаны по всему ассортименту: категории целиком из магнитов + # или целиком из залежавшегося в магазине не бывает. + for category, here in by_category.items(): + assert here == known, category + + +def test_the_demand_is_scattered_and_not_dealt_out_evenly(): + """Одинаковый расклад в каждой категории — та же неправдоподобная ровность. + + Ровно её тикет и вычищал из данных: в живом магазине магнитов в посуде и + в одежде не поровну. Уровень тянется из одной колоды на весь каталог, + поэтому доли категорий расходятся сами. + """ + magnets: dict[str, int] = {} + for row in rows(): + magnets.setdefault(row["category"], 0) + magnets[row["category"]] += row["demand"] == catalog.DEMAND_LEVELS[0] + assert len(set(magnets.values())) > 1, magnets + + +def price_tells_the_level(catalogue: list[dict[str, str]]) -> bool: + """Читается ли уровень спроса по цене товара. + + Спрашивается двумя способами сразу, потому что каждый по отдельности + обманывается: по четвертям цены — все ли три уровня встречаются в каждой, + и по медиане цены уровня — не съехала ли она от медианы каталога. Одни + только края списка ничего не стерегут: четырёх исключений в самых + дешёвых и самых дорогих строках хватает, чтобы вернуть в них все уровни. + """ + known = set(catalog.DEMAND_LEVELS) + by_price = sorted(catalogue, key=lambda row: int(row["price"])) + step = len(by_price) // 4 + quarters = [by_price[start : start + step] for start in range(0, 4 * step, step)] + if any({row["demand"] for row in part} != known for part in quarters): + return True + + whole = statistics.median(int(row["price"]) for row in catalogue) + for level in catalog.DEMAND_LEVELS: + here = [int(row["price"]) for row in catalogue if row["demand"] == level] + if not 0.7 < statistics.median(here) / whole < 1.3: + return True + return False + + +def derived_from_price(catalogue: list[dict[str, str]]) -> list[dict[str, str]]: + """Подложный каталог: уровень выведен из цены, по краям — исключения. + + Исключения подобраны так, чтобы в самой дешёвой и самой дорогой четверти + нашлись все три уровня: столько и нужно, чтобы обмануть проверку, которая + смотрит только на края списка. + """ + magnet, usual, slow = catalog.DEMAND_LEVELS + by_price = sorted(catalogue, key=lambda row: int(row["price"])) + counted = collections.Counter(row["demand"] for row in catalogue) + deck = [level for level in catalog.DEMAND_LEVELS for _ in range(counted[level])] + forged = [ + dict(row, demand=level) for row, level in zip(by_price, deck, strict=True) + ] + forged[0]["demand"] = slow + forged[-1]["demand"] = magnet + forged[-2]["demand"] = usual + return forged + + +def test_the_demand_level_is_not_the_price_in_disguise(): + """Отклонённый вариант: выводить склонность из цены (спека, раздел 9). + + Жёсткая связь дала бы менти ровно то, что мы в неё вложили: «дорогое + залёживается» читалось бы из каталога, а не из данных. + """ + assert not price_tells_the_level(rows()) + # Сторож сторожа: подмени уровни ценой — проверка обязана покраснеть, + # и краевые исключения её не должны обманывать. + assert price_tells_the_level(derived_from_price(rows())) + + +def test_the_demand_level_reaches_the_arrays_row_by_row(): + goods = catalog.catalog() + assert [catalog.DEMAND_LEVELS[number] for number in goods.demand.tolist()] == [ + row["demand"] for row in rows() + ] + + def test_the_catalog_is_grouped_by_category_whatever_the_file_order(): goods = catalog.catalog() assert goods.count.sum() == goods.sku.size diff --git a/generator/tests/test_commerce.py b/generator/tests/test_commerce.py index a135f12..0a2f6fb 100644 --- a/generator/tests/test_commerce.py +++ b/generator/tests/test_commerce.py @@ -21,6 +21,9 @@ from clickstream_generator.seeds import CANONICAL_SEED, Component WEEKDAY = 2 WEEK = 7 +# Окно эталонного снимка: доли по уровням спроса меряются на нём целиком — +# на одном дне у самого редкого уровня набирается слишком мало карточек. +FORTNIGHT = 14 @pytest.fixture(autouse=True) @@ -40,6 +43,13 @@ def week() -> list[day.Day]: return [day.stream(CANONICAL_SEED, number) for number in range(WEEK)] +@pytest.fixture(scope="module") +def fortnight(week: list[day.Day]) -> list[day.Day]: + """Две недели: неделя пересчитанной не бывает — день чист от соседей.""" + later = range(WEEK, FORTNIGHT) + return week + [day.stream(CANONICAL_SEED, number) for number in later] + + def rows_of(events: day.Day, kind: str) -> dict[str, NDArray[Any]]: """Строки одного типа события — все колонки разом.""" here = events.columns["EventType"] == kind @@ -69,11 +79,58 @@ def test_the_day_carries_both_trade_events(weekday: day.Day): assert carts["WatchID"].size > 2 * orders["WatchID"].size +def visits_with(events: day.Day, page: int) -> set[int]: + """Визиты, у которых эта страница магазина дожила до потока. + + Считаются просмотры страниц, а не строки с такой же страницей: торговое + событие садится на свою страницу и несёт её же, поэтому маска по одному + `page` посчитала бы событие корзины за просмотр карточки. + """ + here = (events.columns["EventType"] == "pageview") & (events.page == page) + return set(events.columns["VisitID"][here].tolist()) + + def confirmed_visits(events: day.Day) -> set[int]: """Визиты, чья страница подтверждения дожила до потока.""" + return visits_with(events, Page.CONFIRMATION) + + +def visits_that_put(events: day.Day) -> set[int]: + """Визиты с событием корзины — по типу события, а не по массивам товаров. + + Строка покупки тоже несёт товары, поэтому «строка с `productID`» — это + не «событие корзины». + """ + return set(rows_of(events, commerce.ADD_TO_CART)["VisitID"].tolist()) + + +def cart_products(events: day.Day) -> list[int]: + """Номера товаров, положенных в корзину, — по позиции на строку.""" + here = events.columns["EventType"] == commerce.ADD_TO_CART + return events.product[here].tolist() + + +def card_views(events: day.Day) -> int: + """Просмотры карточек товара в дне — строками потока, с повторами.""" pageview = events.columns["EventType"] == "pageview" - here = pageview & (events.page == Page.CONFIRMATION) - return set(events.columns["VisitID"][here].tolist()) + return int((pageview & (events.page == Page.PRODUCT)).sum()) + + +def opened_cards(events: day.Day) -> set[tuple[int, int]]: + """Разные карточки визитов: повторный просмотр внутри визита — один раз. + + Так же считается и позиция корзины, поэтому это и есть честный + знаменатель конверсии «просмотр карточки → корзина». + """ + pageview = events.columns["EventType"] == "pageview" + here = pageview & (events.page == Page.PRODUCT) + return set( + zip( + events.columns["VisitID"][here].tolist(), + events.product[here].tolist(), + strict=True, + ) + ) def test_a_confirmation_in_the_stream_always_has_its_purchase(weekday: day.Day): @@ -238,26 +295,63 @@ def test_the_rounded_price_does_not_add_up_to_the_revenue(weekday: day.Day): assert 0.1 < apart / orders["WatchID"].size < 0.9 -def test_the_order_is_narrower_than_the_cart(weekday: day.Day): - """Часть положенных позиций не куплена — иначе сравнивать было бы нечего.""" - carts = rows_of(weekday, commerce.ADD_TO_CART) - orders = rows_of(weekday, commerce.PURCHASE) +def basket_and_order(events: day.Day) -> tuple[list[int], list[int]]: + """По каждому заказу — сколько позиций было в корзине и сколько куплено. + + Заодно проверяется само отношение «заказ из корзины»: пустых заказов не + бывает, купленного мимо корзины не бывает, позиция в заказе одна на товар. + """ + carts = rows_of(events, commerce.ADD_TO_CART) + orders = rows_of(events, commerce.PURCHASE) put: dict[int, set[str]] = {} for visit, article in zip( carts["VisitID"].tolist(), (cell[0] for cell in carts["productID"]), strict=True ): put.setdefault(visit, set()).add(article) - dropped = whole = 0 + baskets: list[int] = [] + sizes: list[int] = [] for visit, bought in zip( orders["VisitID"].tolist(), cells(orders["productID"]), strict=True ): assert bought, "пустых заказов не бывает" assert set(bought) <= put[visit], "куплено то, чего не клали в корзину" assert len(set(bought)) == len(bought), "позиция в заказе одна на товар" - dropped += len(put[visit]) - len(bought) - whole += len(put[visit]) - assert 0.05 < dropped / whole < 0.30 + baskets.append(len(put[visit])) + sizes.append(len(bought)) + return baskets, sizes + + +def test_the_order_is_narrower_than_the_cart(weekday: day.Day): + """Часть положенных позиций не куплена — иначе сравнивать было бы нечего.""" + baskets, sizes = basket_and_order(weekday) + assert sum(baskets) > sum(sizes) + + +def test_the_order_keeps_its_shape_across_the_snapshot(fortnight: list[day.Day]): + """Состав заказа — доли окна снимка, а не одного дня. + + Заказов в дне около 240, и доля однопозиционных гуляет по дням на + несколько пунктов от одной случайности выборки; вилки тикета заданы на + 14 днях, там же они и меряются. Список позиций — единственный носитель + урока про вложенный JSON: схлопнись он в один товар, урок бы умер. + """ + baskets: list[int] = [] + sizes: list[int] = [] + for events in fortnight: + was, bought = basket_and_order(events) + baskets += was + sizes += bought + + assert 1.6 < sum(sizes) / len(sizes) < 2.2 + assert 0.40 < sum(size == 1 for size in sizes) / len(sizes) < 0.60 + # Брошено не ничего и не всё. Вилка та же, что и до правки: число мира + # опустилось с 15% до 10%, замер по покупающим корзинам — с 12,0% до + # 7,2%, и запас до нижней границы остался в полтора раза. Ниже её + # ронять незачем: с ней сторож замечает, что число мира срезали вдвое, + # а без неё — уже нет. + dropped = 1 - sum(sizes) / sum(baskets) + assert 0.05 < dropped < 0.30 def test_the_cart_holds_only_what_the_visitor_opened(weekday: day.Day): @@ -284,6 +378,102 @@ def test_the_cart_holds_only_what_the_visitor_opened(weekday: day.Day): assert len(set(products)) == len(products) +def test_putting_something_in_the_cart_is_not_opening_the_cart_page( + fortnight: list[day.Day], +): + """Положил и корзину не открыл — самый массовый сюжет магазина. + + До правки два множества совпадали в точности все 14 дней, и факт + добавления идеально предсказывался более поздним просмотром страницы — + такого равенства в живых данных не бывает. Сторож меряет долю, а не факт + различия одним визитом: одно расхождение прошлую ложь не лечит. + """ + quiet = putting = 0 + for events in fortnight: + put = visits_that_put(events) + quiet += len(put - visits_with(events, Page.CART)) + putting += len(put) + assert quiet / putting > 1 / 3 + + +def test_a_visit_that_opened_the_cart_page_has_something_in_the_cart( + fortnight: list[day.Day], +): + """Страница корзины при пустой корзине невозможна. + + Это включение намеренное и остаётся: ломалось обратное. Проверяется на + обеих неделях — и на буднях, и на выходных: правило про всякий визит, а + не про удачный день. + """ + for events in fortnight: + assert visits_with(events, Page.CART) <= visits_that_put(events) + + +def test_no_demand_level_is_locked_behind_the_cart_page(fortnight: list[day.Day]): + """Отвязка от страницы корзины — про весь ассортимент, а не про часть. + + Обнули вероятность целому уровню — и по его товарам вернётся ровно то + равенство, ради которого правка делалась: «положили — значит, откроют + корзину», без единого исключения на четверти каталога. Сторож меряет + долю у каждого уровня, а не факт: одно событие такую примету не лечит. + """ + goods = catalog.catalog() + levels = range(len(catalog.DEMAND_LEVELS)) + quiet = [0] * len(catalog.DEMAND_LEVELS) + put = [0] * len(catalog.DEMAND_LEVELS) + for events in fortnight: + shopping = visits_with(events, Page.CART) + here = events.columns["EventType"] == commerce.ADD_TO_CART + for visit, number in zip( + events.columns["VisitID"][here].tolist(), + events.product[here].tolist(), + strict=True, + ): + put[goods.demand[number]] += 1 + quiet[goods.demand[number]] += visit not in shopping + + for level in levels: + assert quiet[level] / put[level] > 0.10, catalog.DEMAND_LEVELS[level] + + +def test_the_demand_level_tells_the_cart_conversion_apart(fortnight: list[day.Day]): + """Привлекательность товара стала измеримой величиной, а не шумом. + + Знаменатель — разные карточки товаров этого уровня: повторный просмотр + внутри визита считается один раз, так же как считается позиция. До + правки решение принималось один раз на визит, и по sku выходил чистый + шум: внутри воронки 100%, вне её 0%. + """ + goods = catalog.catalog() + levels = range(len(catalog.DEMAND_LEVELS)) + shown = [0] * len(catalog.DEMAND_LEVELS) + put = [0] * len(catalog.DEMAND_LEVELS) + for events in fortnight: + for _, number in opened_cards(events): + shown[goods.demand[number]] += 1 + for number in cart_products(events): + put[goods.demand[number]] += 1 + + share = [put[level] / shown[level] for level in levels] + magnet, usual, slow = share + assert magnet > usual > slow + assert magnet >= 2 * slow + + +def test_the_cart_events_stay_inside_the_event_budget(fortnight: list[day.Day]): + """Меняется не сколько кладут, а кто и что: бюджет событий на месте. + + Доля просмотров карточек, дошедших до корзины, — та же величина, что + держала бюджет до правки; средний день остаётся около 50 тыс. событий + (спека генератора, разделы 5 и 9). + """ + put = sum(len(cart_products(events)) for events in fortnight) + seen = sum(card_views(events) for events in fortnight) + assert 0.05 < put / seen < 0.09 + average = sum(len(events) for events in fortnight) / len(fortnight) + assert 48_000 < average < 52_000 + + def test_a_trade_event_sits_on_the_page_that_sent_it(weekday: day.Day): """Корзина — на карточке того самого товара, покупка — на подтверждении. diff --git a/generator/tests/test_world.py b/generator/tests/test_world.py index 5724f07..872b16f 100644 --- a/generator/tests/test_world.py +++ b/generator/tests/test_world.py @@ -6,7 +6,7 @@ «в среднем 3–4 возврата» и «средняя кука активна ≈1,9 дня». """ -from clickstream_generator import world +from clickstream_generator import catalog, world def mean_by_weights(values: tuple[int, ...], weights: tuple[int, ...]) -> float: @@ -150,6 +150,23 @@ def test_the_buyer_mark_shows_on_both_steps_of_the_funnel(): assert 2 <= lift <= 6 +def test_the_demand_levels_keep_their_order_and_yield_to_the_intent(): + """Ряды вероятностей читаются по уровням каталога, и оба убывают. + + Совпадение длин — не формальность: ряды индексируются номером уровня, + и новый уровень в файле каталога обязан получить здесь своё число. + """ + rows = (world.SHOPPING_ADD_PERCENT, world.BROWSING_ADD_PERCENT) + for row in rows: + assert len(row) == len(catalog.DEMAND_LEVELS) + assert row[0] > row[1] > row[2], row + # Намерение сильнее товара: визит, пришедший покупать, кладёт чаще на + # любом уровне — иначе слово «намерение» ничего бы не значило. + assert all(shopping > browsing for shopping, browsing in zip(*rows, strict=True)) + # Но и не всё подряд: товар решает у обоих, спрос не декорация. + assert min(world.SHOPPING_ADD_PERCENT) < 100 + + def test_the_buyer_mark_makes_the_cookie_live_longer(): """Второй рычаг метки: без долгой жизни второй покупке негде случиться.""" assert world.BUYER_ONE_SHOT_PERCENT < world.ONE_SHOT_PERCENT -- 2.54.0 From 4243aa2c88d5a16fe0167f73459ada1c2a35457d Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Sun, 2 Aug 2026 22:54:31 +0300 Subject: [PATCH 8/8] =?UTF-8?q?docs(generator):=20=D0=B2=D0=B8=D0=BB=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BA=D0=BE=D0=BD=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D0=B8?= =?UTF-8?q?=20=D0=B2=20=D0=BA=D0=BE=D1=80=D0=B7=D0=B8=D0=BD=D1=83=20?= =?UTF-8?q?=E2=80=94=20=D0=BE=D1=80=D0=B8=D0=B5=D0=BD=D1=82=D0=B8=D1=80,?= =?UTF-8?q?=20=D0=B0=20=D0=BD=D0=B5=20=D0=B3=D1=80=D0=B0=D0=BD=D0=B8=D1=86?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - после #50 доля просмотров, доходящих до корзины, подошла к краю вилки 5–9%, и следующий исполнитель стал бы калибровать поведение под это число. Владелец решил: пока доля правдоподобна, она ничего не сторожит — у живых магазинов она гуляет широко. - Что: - в блок «Решено при исполнении #50» добавлено решение владельца: вилка — ориентир; настоящий предел здесь дневной бюджет событий, на котором стоят манифест (#42) и порог скорости дня. - Проверка: - git show --stat; следующий исполнитель читает решение, не переспрашивая. --- docs/specs/2026-08-01-generator.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/specs/2026-08-01-generator.md b/docs/specs/2026-08-01-generator.md index 5ac2dd3..c4a70f0 100644 --- a/docs/specs/2026-08-01-generator.md +++ b/docs/specs/2026-08-01-generator.md @@ -838,6 +838,14 @@ pytest-тест с маркером `perf` и таймаутом-обрубан намерением клал почти всё сравненное. Обе границы тянут число вверх, и опустить его — значит отпустить одну из них. Место до 9% невелико, и следующая правка торговой половины считается против него. +- **Вилка 5–9% — ориентир, а не граница** (решение владельца, 2026-08-02). + Доля просмотров, доходящих до корзины, у живых магазинов гуляет широко: + пока число правдоподобно, само по себе оно ничего не сторожит, и + подгонять поведение под его край не надо. Настоящий предел здесь другой и + считается в другой валюте — дневной бюджет событий: события корзины + входят в те самые «около 50 тыс.», на которых стоят манифест (#42) и + порог скорости дня. Упрётся будущая правка — двигать надо бюджет и его + причины, а не долю. Остаётся открытым, за тикетами: -- 2.54.0