From eb433ad0235dbcec8c06bbd65b306d6a487476d1 Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Sun, 2 Aug 2026 16:12:17 +0300 Subject: [PATCH] =?UTF-8?q?feat(generator):=20=D0=B4=D0=B5=D0=BD=D1=8C-?= =?UTF-8?q?=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D1=8F=20=E2=80=94=20=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D1=84=D0=B8=D0=BA,=20=D0=B2=D0=B8=D0=B7=D0=B8?= =?UTF-8?q?=D1=82=D1=8B=20=D0=B8=20=D0=BF=D1=80=D0=BE=D1=81=D0=BC=D0=BE?= =?UTF-8?q?=D1=82=D1=80=D1=8B=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Зачем: план состава отдаёт дневную аудиторию, но событий у мира ещё не было. День-функция превращает аудиторию в поток просмотров — на нём стоят лабы про сборку визитов и про витрины, а следующий этап вешает на него торговые события. Что: - `day.py` — день как чистая функция зерна и номера дня: суточная волна в местном времени посетителя, визиты по документированным правилам нарезки, все 47 колонок выгрузки; шов для торговых событий — ряды `page` и `product`; - `reference.py` — справочники-литералы: профили устройств, города Поволжья с настоящими гео-id Яндекса, источники трафика, карта сайта; - `catalog.py` и `data/catalog/products.csv` — каталог на 180 позиций, общий у генератора и будущего словаря ClickHouse; - `weights.py` — выбор по целым весам, один на план и на день; - паспорт куки (устройство и город) переехал в план состава; броски приписаны последними, поэтому измеренные числа канонического мира не сдвинулись; - словарь: «визит» закреплён за сессией, одноимённое понятие плана стало «днём активности»; статьи в `CONTEXT.md`; - решения по ходу — в спеку генератора, раздел 9; наполнение `ParsedParamsKey1` отложено тикетом #47. Проверка: `make lint`, `make typecheck`, `make test` — 353 passed (было 297). Счётчики плана после правки те же: приток 3827,64/день, дневная аудитория 6235–7124, 68 119 посетителей за 14 дней, 170 двухкуковых пар. День 0 — 45 810 событий за 0,6 с, снимок 14 дней — 5,9 с при пороге 30 с на день. Две слепые линии ревью, десять находок, все закрыты и перепроверены. Co-Authored-By: Claude Opus 5 (1M context) --- CONTEXT.md | 34 +- data/catalog/products.csv | 181 ++++++ docs/formats/clickstream-event.md | 4 +- docs/specs/2026-08-01-generator.md | 112 +++- generator/README.md | 24 +- .../src/clickstream_generator/__init__.py | 4 + .../src/clickstream_generator/catalog.py | 93 ++++ generator/src/clickstream_generator/day.py | 525 ++++++++++++++++++ generator/src/clickstream_generator/plan.py | 176 ++++-- .../src/clickstream_generator/reference.py | 252 +++++++++ generator/src/clickstream_generator/schema.py | 8 +- .../src/clickstream_generator/weights.py | 37 ++ generator/src/clickstream_generator/world.py | 75 ++- generator/tests/test_catalog.py | 79 +++ generator/tests/test_day.py | 357 ++++++++++++ generator/tests/test_plan.py | 72 ++- generator/tests/test_reference.py | 92 +++ generator/tests/test_world.py | 53 ++ 18 files changed, 2073 insertions(+), 105 deletions(-) create mode 100644 data/catalog/products.csv create mode 100644 generator/src/clickstream_generator/catalog.py create mode 100644 generator/src/clickstream_generator/day.py create mode 100644 generator/src/clickstream_generator/reference.py create mode 100644 generator/src/clickstream_generator/weights.py create mode 100644 generator/tests/test_catalog.py create mode 100644 generator/tests/test_day.py create mode 100644 generator/tests/test_reference.py diff --git a/CONTEXT.md b/CONTEXT.md index 18a786c..593d27f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -31,8 +31,8 @@ _Избегать_: состояние мира там колонки, здесь числа. **Когорта дня**: -Люди, впервые пришедшие в мир в один день, со всеми их куками и визитами. -Единица плана состава: когорта — функция зерна и номера дня. +Люди, впервые пришедшие в мир в один день, со всеми их куками и днями +активности. Единица плана состава: когорта — функция зерна и номера дня. **Приток**: Появление новых кук на всём протяжении оси модельного времени; единица — @@ -40,11 +40,37 @@ _Избегать_: состояние мира горизонтом и не совпадает с дневной. **Хвост возвратов**: -Окно активности, отсчитанное от первого визита человека и общее на обе +Окно активности, отсчитанное от первого дня человека и общее на обе его куки; дольше окна кука не возвращается. **Предыстория**: -Когорты плана с первым визитом до D0; событий не порождают. +Когорты плана с первым днём активности до D0; событий не порождают. + +**День активности**: +День, в который кука хоть раз появилась в мире: день её рождения или день +возврата. Единица плана состава: за день он у куки один, а визитов внутри +него бывает несколько. +_Избегать_: визит (визит — про сессию) + +**Визит**: +Подряд идущие события одной куки без пауз длиннее 30 минут, не пересекающие +границу суток. Поле `VisitID` — эталон для лабы сессий. «Сессия» — то же +понятие словами аналитики: в данных и в коде оно зовётся визитом, но имена +вроде «лаба сессий» и «сборка сессий» остаются. + +**Паспорт куки**: +Устройство и город, приписанные куке на всю жизнь: кука — это браузер на +устройстве. Держит их план состава, разворачивает в поля события +день-функция; у двухкуковой пары город один на две куки, устройства разные. + +**День-функция**: +Функция (зерно, номер дня), выдающая упорядоченный поток событий этих +модельных суток. Состояния между днями нет: день D не зависит от того, +прожиты ли дни до него. + +**Каталог товаров**: +`data/catalog/products.csv` — общий справочник генератора и словаря +ClickHouse. Форма файла решена, длина — нет: строки дописываются. **Ось модельного времени**: Собственный календарь мира генератора. Дни считаются от фиксированного diff --git a/data/catalog/products.csv b/data/catalog/products.csv new file mode 100644 index 0000000..4f5f017 --- /dev/null +++ b/data/catalog/products.csv @@ -0,0 +1,181 @@ +sku,name,category,brand,price +HOME-0001,Комплект органайзеров для шкафа,Товары для дома,Домовит,129000 +HOME-0002,Сушилка для белья напольная,Товары для дома,Домовит,249900 +HOME-0003,Настольная лампа «Тихий вечер»,Товары для дома,Ладный дом,189000 +HOME-0004,Набор вешалок 12 штук,Товары для дома,Домовит,79000 +TEXT-0001,Комплект постельного белья полутораспальный,Текстиль,Сатин,349000 +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 +POSU-0004,Набор столовых приборов 24 предмета,Посуда,Ладья,289000 +TECH-0001,Электрочайник 1.7 л,Бытовая техника,Электрон,189000 +TECH-0002,Блендер погружной 800 Вт,Бытовая техника,Электрон,279000 +TECH-0003,Отпариватель ручной,Бытовая техника,Электрон,349000 +TECH-0004,Робот-пылесос,Бытовая техника,Электрон,1899000 +KIDS-0001,Стульчик для кормления,Детские товары,Малышок,649000 +KIDS-0002,Ночник-проектор «Звёзды»,Детские товары,Малышок,129000 +KIDS-0003,Набор детской посуды 5 предметов,Детские товары,Малышок,89000 +WEAR-0001,Тапочки домашние войлочные,Одежда и обувь,Ходики,99000 +WEAR-0002,Халат махровый,Одежда и обувь,Сатин,249000 +WEAR-0003,Носки хлопковые 5 пар,Одежда и обувь,Ходики,59000 +HOME-0005,Корзина для белья 45 л,Товары для дома,Домовит,119000 +HOME-0006,Контейнер для хранения 18 л,Товары для дома,Домовит,69000 +HOME-0007,Чехол для одежды 60×100,Товары для дома,Ладный дом,39000 +HOME-0008,Коврик придверный резиновый 40×60,Товары для дома,Домовит,59000 +HOME-0009,Гладильная доска настольная 73×30,Товары для дома,Ладный дом,149000 +HOME-0010,Ведро хозяйственное с крышкой 12 л,Товары для дома,Домовит,49000 +HOME-0011,Швабра с отжимом и ведром 8 л,Товары для дома,Домовит,179000 +HOME-0012,Щётка для пола с черенком 120 см,Товары для дома,Домовит,45000 +HOME-0013,Совок со щёткой на длинной ручке,Товары для дома,Домовит,79000 +HOME-0014,Таз хозяйственный круглый 14 л,Товары для дома,Домовит,39000 +HOME-0015,Этажерка для ванной 3 яруса,Товары для дома,Ладный дом,139000 +HOME-0016,Полка настенная металлическая 45 см,Товары для дома,Ладный дом,99000 +HOME-0017,Зеркало настенное круглое 50 см,Товары для дома,Ладный дом,169000 +HOME-0018,Набор коробок для обуви 6 штук,Товары для дома,Домовит,129000 +HOME-0019,Подставка для зонтов металлическая,Товары для дома,Ладный дом,159000 +HOME-0020,Крючки самоклеящиеся 8 штук,Товары для дома,Домовит,29000 +HOME-0021,Верёвка бельевая 20 м,Товары для дома,Домовит,19000 +HOME-0022,Прищепки усиленные 24 штуки,Товары для дома,Домовит,25000 +HOME-0023,Мешки вакуумные для одежды 5 штук,Товары для дома,Домовит,69000 +HOME-0024,Органайзер подвесной с 12 карманами,Товары для дома,Ладный дом,79000 +HOME-0025,Часы настенные круглые 30 см,Товары для дома,Ладный дом,89000 +HOME-0026,Светильник настольный с прищепкой 7 Вт,Товары для дома,Ладный дом,119000 +HOME-0027,Удлинитель бытовой 3 розетки 3 м,Товары для дома,Ладный дом,99000 +HOME-0028,Лестница-стремянка 3 ступени,Товары для дома,Домовит,299000 +HOME-0029,Ящик для инструментов 16 дюймов,Товары для дома,Домовит,139000 +HOME-0030,Набор плечиков для брюк 6 штук,Товары для дома,Домовит,89000 +TEXT-0005,Пододеяльник двуспальный 175×215,Текстиль,Сатин,169000 +TEXT-0006,Простыня на резинке 160×200,Текстиль,Сатин,149000 +TEXT-0007,Наволочки хлопковые 50×70 2 штуки,Текстиль,Сатин,79000 +TEXT-0008,Одеяло всесезонное 172×205,Текстиль,Сатин,299000 +TEXT-0009,Подушка стёганая 50×70,Текстиль,Сатин,129000 +TEXT-0010,Наматрасник непромокаемый 160×200,Текстиль,Сатин,189000 +TEXT-0011,Покрывало стёганое 180×210,Текстиль,Сатин,239000 +TEXT-0012,Плед флисовый 150×200,Текстиль,Сатин,119000 +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 штуки,Текстиль,Ладный дом,109000 +TEXT-0023,Подушка декоративная 40×40,Текстиль,Сатин,69000 +TEXT-0024,Чехол для диванной подушки 45×45,Текстиль,Сатин,39000 +TEXT-0025,Фартук кухонный с карманом,Текстиль,Ладный дом,59000 +TEXT-0026,Прихватки стёганые 20×20 2 штуки,Текстиль,Ладный дом,39000 +TEXT-0027,Халат вафельный размер 48–50,Текстиль,Сатин,199000 +TEXT-0028,Комплект полотенец 50×90 и 70×140,Текстиль,Сатин,139000 +TEXT-0029,Одеяло детское байковое 100×140,Текстиль,Сатин,169000 +TEXT-0030,Подушка для стула 40×40,Текстиль,Ладный дом,59000 +POSU-0005,Кастрюля эмалированная 3 л,Посуда,Кухмастер,169000 +POSU-0006,Ковш нержавеющий с крышкой 1.5 л,Посуда,Кухмастер,139000 +POSU-0007,Сотейник литой с крышкой 28 см,Посуда,Кухмастер,269000 +POSU-0008,Сковорода блинная 22 см,Посуда,Кухмастер,149000 +POSU-0009,Казан алюминиевый 6 л,Посуда,Кухмастер,299000 +POSU-0010,Форма для запекания стеклянная 2.4 л,Посуда,Кухмастер,119000 +POSU-0011,Противень стальной 37×27,Посуда,Кухмастер,79000 +POSU-0012,Набор форм для выпечки 3 штуки,Посуда,Кухмастер,139000 +POSU-0013,Миска нержавеющая 24 см,Посуда,Кухмастер,69000 +POSU-0014,Дуршлаг стальной с ручками 22 см,Посуда,Кухмастер,89000 +POSU-0015,Тёрка четырёхгранная 23 см,Посуда,Кухмастер,49000 +POSU-0016,Доска разделочная бамбуковая 35×25,Посуда,Кухмастер,79000 +POSU-0017,Набор ножей с подставкой 6 предметов,Посуда,Кухмастер,249000 +POSU-0018,Половник и шумовка 2 предмета,Посуда,Кухмастер,59000 +POSU-0019,Контейнеры пищевые 7 предметов,Посуда,Кухмастер,99000 +POSU-0020,Банки для сыпучих продуктов 4 штуки,Посуда,Ладья,139000 +POSU-0021,Тарелки обеденные фарфоровые 6 штук,Посуда,Ладья,179000 +POSU-0022,Тарелки суповые керамические 4 штуки,Посуда,Ладья,129000 +POSU-0023,Салатник стеклянный 23 см,Посуда,Ладья,69000 +POSU-0024,Кружки керамические 350 мл 4 штуки,Посуда,Ладья,119000 +POSU-0025,Стаканы высокие 400 мл 6 штук,Посуда,Ладья,99000 +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 +TECH-0007,Стационарный блендер 1.8 л,Бытовая техника,Электрон,549000 +TECH-0008,Мясорубка электрическая 1800 Вт,Бытовая техника,Электрон,799000 +TECH-0009,Тостер на 2 ломтика 850 Вт,Бытовая техника,Электрон,299000 +TECH-0010,Вафельница электрическая 1000 Вт,Бытовая техника,Электрон,349000 +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 кг,Бытовая техника,Электрон,199000 +TECH-0028,Весы кухонные до 5 кг,Бытовая техника,Электрон,149000 +TECH-0029,Увлажнитель воздуха 3 л,Бытовая техника,Электрон,499000 +TECH-0030,Тепловентилятор керамический 1500 Вт,Бытовая техника,Электрон,399000 +KIDS-0004,Кроватка кукольная деревянная 45 см,Детские товары,Малышок,249000 +KIDS-0005,Манеж складной 100×100,Детские товары,Малышок,799000 +KIDS-0006,Горшок детский со спинкой,Детские товары,Малышок,79000 +KIDS-0007,Ванночка детская 84 см,Детские товары,Малышок,169000 +KIDS-0008,Подставка детская двухступенчатая,Детские товары,Малышок,89000 +KIDS-0009,Накладка на унитаз с ручками,Детские товары,Малышок,69000 +KIDS-0010,Сиденье для купания на присосках,Детские товары,Малышок,139000 +KIDS-0011,Пелёнки муслиновые 80×100 3 штуки,Детские товары,Малышок,129000 +KIDS-0012,Одеяло для коляски 90×110,Детские товары,Малышок,149000 +KIDS-0013,Нагрудники непромокаемые 5 штук,Детские товары,Малышок,59000 +KIDS-0014,Бутылочка с ручками 250 мл,Детские товары,Малышок,49000 +KIDS-0015,Поильник непроливайка 300 мл,Детские товары,Малышок,55000 +KIDS-0016,Контейнер для пустышки 2 штуки,Детские товары,Малышок,25000 +KIDS-0017,Погремушки мягкие 3 штуки,Детские товары,Малышок,59000 +KIDS-0018,Кубики развивающие 12 штук,Детские товары,Малышок,89000 +KIDS-0019,Пирамидка деревянная 8 колец,Детские товары,Малышок,79000 +KIDS-0020,Сортер с геометрическими фигурами,Детские товары,Малышок,99000 +KIDS-0021,Конструктор деревянный 50 деталей,Детские товары,Малышок,139000 +KIDS-0022,Доска для рисования магнитная 30×40,Детские товары,Малышок,129000 +KIDS-0023,Мольберт детский двусторонний 105 см,Детские товары,Малышок,299000 +KIDS-0024,Набор для лепки 12 цветов,Детские товары,Малышок,69000 +KIDS-0025,Коврик игровой складной 180×150,Детские товары,Малышок,399000 +KIDS-0026,Палатка игровая с окошками,Детские товары,Малышок,199000 +KIDS-0027,Каталка с ручкой и звуком,Детские товары,Малышок,169000 +KIDS-0028,Самокат трёхколёсный до 50 кг,Детские товары,Малышок,299000 +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 +WEAR-0009,Брюки спортивные размер 50,Одежда и обувь,Сатин,169000 +WEAR-0010,Легинсы хлопковые размер 46,Одежда и обувь,Сатин,99000 +WEAR-0011,Пижама трикотажная размер 48,Одежда и обувь,Сатин,179000 +WEAR-0012,Ночная сорочка хлопковая размер 50,Одежда и обувь,Сатин,129000 +WEAR-0013,Фартук рабочий с тремя карманами,Одежда и обувь,Сатин,79000 +WEAR-0014,Перчатки хозяйственные 3 пары,Одежда и обувь,Ходики,39000 +WEAR-0015,Варежки вязаные размер 8,Одежда и обувь,Ходики,69000 +WEAR-0016,Шапка вязаная с отворотом размер 56,Одежда и обувь,Ходики,89000 +WEAR-0017,Шарф шерстяной 30×180,Одежда и обувь,Ходики,119000 +WEAR-0018,Ремень текстильный длиной 120 см,Одежда и обувь,Ходики,79000 +WEAR-0019,Стельки войлочные 2 пары,Одежда и обувь,Ходики,39000 +WEAR-0020,Шнурки круглые 120 см 3 пары,Одежда и обувь,Ходики,25000 +WEAR-0021,Кеды текстильные размер 42,Одежда и обувь,Ходики,179000 +WEAR-0022,Кроссовки прогулочные размер 39,Одежда и обувь,Ходики,249000 +WEAR-0023,Ботинки утеплённые размер 41,Одежда и обувь,Ходики,349000 +WEAR-0024,Сапоги резиновые размер 40,Одежда и обувь,Ходики,199000 +WEAR-0025,Полуботинки кожаные размер 43,Одежда и обувь,Ходики,399000 +WEAR-0026,Сандалии с ремешками размер 38,Одежда и обувь,Ходики,189000 +WEAR-0027,Сланцы резиновые размер 41,Одежда и обувь,Ходики,79000 +WEAR-0028,Балетки текстильные размер 37,Одежда и обувь,Ходики,149000 +WEAR-0029,Туфли домашние утеплённые размер 39,Одежда и обувь,Ходики,129000 +WEAR-0030,Галоши садовые размер 42,Одежда и обувь,Ходики,99000 diff --git a/docs/formats/clickstream-event.md b/docs/formats/clickstream-event.md index a40bd89..7c2fee3 100644 --- a/docs/formats/clickstream-event.md +++ b/docs/formats/clickstream-event.md @@ -31,8 +31,8 @@ JSON-поле `ecommerce`. Отдельной сущности «визит» в | 2 | `VisitID` | `UInt64` | `uint64` | `visit_id` | id визита от генератора — эталон лабы: собери сессии сам и сравни | | 3 | `ClientID` | `UInt64` | `uint64` | `client_id` | анонимный id браузера — кука; по хешу от неё таблица шардируется | | 4 | `CounterID` | `UInt32` | `uint32` | `counter_id` | id счётчика: на стенде константа, сайт один | -| 5 | `EventDate` | `Date` | `datetime64[D]` | `event_date` | дата события; по ней режется партиция | -| 6 | `UTCEventTime` | `DateTime` | `datetime64[s]` | `utc_event_time` | время события в UTC — единственная метка времени, как у Метрики | +| 5 | `EventDate` | `Date` | `datetime64[D]` | `event_date` | дата события в часовом поясе счётчика; по ней режется партиция. Дату из `UTCEventTime` не выводить: у ночных событий она на сутки другая | +| 6 | `UTCEventTime` | `DateTime` | `datetime64[s]` | `utc_event_time` | время события в UTC — единственная метка времени, как у Метрики; сутки же считаются в поясе счётчика, поэтому `toDate(UTCEventTime)` ≠ `EventDate` | | 7 | `ClientTimeZone` | `Int16` | `int16` | `client_timezone` | смещение часового пояса клиента от UTC, в минутах | | 8 | `EventType` | `LowCardinality(String)` | `object` | `event_type` | тип события: pageview, add_to_cart, purchase — добавка стенда, у Метрики такого поля нет | | 9 | `Sign` | `Int8` | `int8` | `sign` | всегда 1: колонка формата, исправлений записей генератор не шлёт | diff --git a/docs/specs/2026-08-01-generator.md b/docs/specs/2026-08-01-generator.md index 166a799..5617957 100644 --- a/docs/specs/2026-08-01-generator.md +++ b/docs/specs/2026-08-01-generator.md @@ -55,7 +55,7 @@ торговые счётчики сложатся, когда торговое поведение определит #40. - **Состав не замкнут: посетители появляются и затухают.** План состава задаёт календарь появления — у каждого посетителя есть дата первого - визита и профиль возвратов, включая затухание: заметная доля кук + дня и профиль возвратов, включая затухание: заметная доля кук одноразовая, как в живом трафике. Новые посетители появляются на всём протяжении оси: uniq(ClientID) растёт с горизонтом, дневная и накопленная аудитории не сходятся в одно число. Приток — часть плана, а не мутация: @@ -67,7 +67,7 @@ ветвящийся по номеру дня (позиция в дереве — раздел 2), отдельный от подпотока дня-функции. Аудитория дня — когорта D плюс возвраты когорт последних дней: окно активности — хвост возвратов (константа мира — - раздел 9), отсчитанный от первого визита человека и общий на обе его + раздел 9), отсчитанный от первого дня человека и общий на обе его куки (уточнение при исполнении #38, 2026-08-02: окно от рождения каждой куки растянуло бы жизнь когорты вдвое, а с ней и загляд назад, которым ленивая форма и держится). За краем окна кука не @@ -89,7 +89,7 @@ Равные шансы по всему окну означали бы вторую куку у давно ушедшего человека — и вчетверо меньше пар, реализованных внутри снимка. Каждой паре план назначает дни гарантированных заказов: по - одному на куку, из дней визитов этой куки, на оси от D0 и позже; + одному на куку, из дней активности этой куки, на оси от D0 и позже; человеку предыстории, чьё окно активности таких дней не оставляет, пара не назначается. День-функция обязана назначенные заказы реализовать; остальные покупки — вольные, их решает генератор торговых @@ -405,12 +405,13 @@ pytest-тест с маркером `perf` и таймаутом-обрубан Решено при исполнении #38 (2026-08-02): - **Числа притока и состава.** Приток — ~3 800 новых кук в средний день, - модулируется тем же недельным профилем, что трафик (иначе доля - новичков скакала бы по дням недели). Доля одноразовых кук — 75%; + модулируется недельным профилем мира; трафик наследует эту волну через + дневную аудиторию, а не отдельным умножением (уточнение при исполнении + #39 — ниже). Доля одноразовых кук — 75%; возвращающиеся — в среднем 3–4 возврата, профиль убывающий: почти все в первые 7–10 дней, тонкий хвост поздних возвратов и повторных покупок — до края окна (цикл повторной покупки магазина — месяцы). - Хвост возвратов — окно активности человека от его первого визита, + Хвост возвратов — окно активности человека от его первого дня, общее на обе его куки, — и глубина предыстории: 90 дней (решение владельца 2026-08-02: дольше квартала стенд никто не гоняет, а заказы старых посетителей продолжаются весь прогон; плата — чуть меньше пар, @@ -447,6 +448,100 @@ pytest-тест с маркером `perf` и таймаутом-обрубан смыслу равен модулю, но платит загрузчиком и валидацией (довод раздела 3 против YAML). +Решено при исполнении #39 (2026-08-02) — решения владельца до реализации: + +- **Каталог товаров заводится здесь, а не в #40.** У карточки товара есть + `Title` — имя товара, а имена живут в каталоге: карточка без каталога + невозможна, и без карточек #39 сделал бы примерно половину трафика. + Файл `data/catalog/products.csv` (`sku`, `name`, `category`, `brand`, + `price`) — общий у генератора и словаря ClickHouse; #40 получает готовый. + Решена форма, а не длина: артикул — четыре буквы категории и четыре + цифры, цена — целые копейки, категорий шесть. Строки дописываются + механически, поэтому ни код, ни тесты их не считают, а товар для карточки + выбирается равномерно внутри категории. +- **Ассортимент — непродовольственная розница** (товары для дома, текстиль, + посуда, мелкая бытовая техника, детское, одежда и обувь), ~150–200 sku. + Довод не вкусовой: числа мира приняты под этот профиль — цикл повторной + покупки в месяцы, 75% одноразовых кук, конверсия ~2% на визит. + Продуктовая сеть требовала бы других чисел, то есть переоткрытия #38. +- **География — один регион присутствия**: Поволжье с центром в Самаре, + города своего региона и тонкий хвост остальной страны. «Топ городов + России» дал бы магазину с одним складом карту, которой у него не бывает. +- **Модельные сутки считаются в часовом поясе счётчика** (UTC+4), как в + выгрузке Метрики: `EventDate` — дата в поясе счётчика, `UTCEventTime` — + абсолютная метка. Шов суток приходится на местную полночь и не режет + утренние сессии, а регион мира выбирается свободно. Суточная волна задана + в местном времени посетителя: гостю из другого пояса профиль + поворачивается на разницу, и пик слегка размазывается — как в жизни. + Следствие для соседних этапов: `toDate(UTCEventTime)` ≠ `EventDate`; оно + записано в описание выгрузки, иначе сторона хранилища выведет дату сама и + разойдётся на несколько часов данных. +- **Паспорт куки — постоянная часть мира, а не поведение дня.** Браузер, ОС, + устройство, экран и город у куки одни во всех её днях: кука — это браузер + на устройстве. Держит их план состава (`Cohort` и `DayAudience` прирастают + двумя массивами), разворачивает в поля события день-функция. Выводить + паспорт арифметикой из `ClientID` дешевле, но про двухкуковые пары знает + только план, а два города у одного человека — ложь в данных; пара + получает один город и разные устройства («телефон и ноутбук», + мастер-спека, раздел 5). Броски паспорта приписаны последними, поэтому + измеренные числа канонического мира не сдвинулись: прогон счётчиков до и + после дал те же 3 828 / 6 235–7 124 / 68 119 / 170. +- **Справочники — таблицы-литералы** рядом с конфигурацией мира; Faker и + mimesis не подключаются. Нужны не случайные строки, а связки (город → id + региона → часовой пояс; телефон → Safari → iOS → размер экрана) — их + фейкер не даёт, таблицу пришлось бы написать всё равно, а новая + зависимость молча меняла бы мир при обновлении своих словарей. +- **Гео-id — настоящие числа геобазы Яндекса.** Проверка 2026-08-02: id + подтверждались обращением к живым сервисам Яндекса по тому же номеру + (`yandex.ru/pogoda/`, `yandex.ru/maps/225/russia/`) — страница + открывает ожидаемое место. Оговорка стоит в коде: опубликованной таблицы + геобазы найти не удалось, а что `RegionCityID` Метрики нумерует регионы + той же геобазой — обоснованное допущение, не подтверждённый источником + факт. Выдуманные по памяти числа не годятся ни в каком случае. +- **IP-адреса — нероутируемые диапазоны**: документационные сети RFC 5737 и + benchmark-сеть 198.18/15 по ломтю на город, телефонам — CGNAT 100.64/10, + как у настоящих операторов. Правдоподобные публичные адреса принадлежат + живым организациям, и в учебных данных им не место. +- **Словарь: «визит» остаётся за сессией** и полем `VisitID`. Одноимённое + понятие плана состава переименовано в «день активности»: у куки он один на + день, а визитов внутри дня бывает несколько. + +Решено при исполнении #39 самой реализацией: + +- **Правила резки визитов** (мастер-спека, раздел 1.2 требовала их + задокументировать): визит принадлежит одной куке; пауза дольше 30 минут + рвёт визит надвое; граница модельных суток режет визит, события за + полночь в день не попадают. Единственное исключение — визит с заказом, + обещанным планом двухкуковых пар: его старт сдвигается назад, чтобы + воронка уместилась в сутки. Это принятое ограничение модели, а не + недосмотр: обещание плана — гарантия, на которой стоит лаба склейки, и + ради неё мы сужаем свободу старта. Цена названа и мала: около 24 визитов + в день никогда не начинаются в последние минуты суток (из ~9,5 тыс.). + Правила лежат в докстринге `day.py`, и тест собирает визиты по ним + заново, сверяя сборку с `VisitID`. +- **Числа дня**: ≈1,4 визита на день активности куки и ≈4,8 страницы на + визит — 9,5 тыс. визитов и ~45 тыс. pageview в средний день; воронка + 8% визитов до корзины → 45% из них до оформления → 55% из них до + подтверждения, то есть конверсия визита ~2%. Итог сложится после + торговых событий (#40) и ляжет в манифест. +- **Недельная волна применяется один раз.** Профиль ведёт приток, трафик + наследует его через дневную аудиторию; измеренный размах трафика — ±7% + против ±10% у притока. Второе умножение удвоило бы недельный размах. Если + недельной лабе однажды не хватит сигнала, принципиальный путь — отдельный, + более слабый профиль активности, а не повторное применение профиля + притока: механизмы разные, и «на выходных приходит меньше новых людей» — + не тот же факт, что «на выходных каждый ходит меньше». +- **`ParsedParamsKey1` остаётся пустой** — это отложенное решение, а не + дыра: вариант A/B-теста был бы постоянной куки, а не поведением дня, и + заводится тикетом #47. +- **Шов для #40**: день отдаёт, кроме колонок, два выровненных по строкам + ряда — какая это страница магазина и какой товар показывала карточка. По + ним #40 знает, куда вешать торговое событие и что посетитель на самом + деле смотрел: товар в корзине, которого никто не открывал, — видимая + глупость в воронке. Визит с назначенным заказом всегда доходит до + `/confirmation`, а перед корзиной у него всегда есть карточка. Своей + случайности #39 у #40 не занимает: подпоток `COMMERCE` не тронут. + Остаётся открытым, за тикетами: - интерфейс запуска генератора (CLI / цели make) и как он делит режимы @@ -454,4 +549,7 @@ pytest-тест с маркером `perf` и таймаутом-обрубан оценки мастер-спеки (раздел 9) — и в каком контейнере он живёт (#41); - как фиксируется «зерновой» мир конца этапа 2 (раздел 9 мастер-спеки): с манифестным решением напрашивается мини-манифест зернового мира — - форма за #42. + форма за #42; +- паспорт мира в манифесте (зерно и версия генератора) файл каталога не + накрывает: правка цены в CSV меняет мир молча. Манифесту нужен хеш + каталога — хвост для #42. diff --git a/generator/README.md b/generator/README.md index f108d93..8faa725 100644 --- a/generator/README.md +++ b/generator/README.md @@ -4,8 +4,8 @@ образцу облачной выгрузки Яндекс Метрики. Устройство и принятые решения — спека [«Генератор (этап 2)»](../docs/specs/2026-08-01-generator.md). -Пока здесь контракт схемы события и план состава мира — событий генератор ещё -не порождает. +События уже есть: день-функция отдаёт по паре (зерно, D) упорядоченный поток +pageview. Торговые события и запуск снаружи — за следующими тикетами. ## Что где лежит @@ -14,16 +14,28 @@ - `src/clickstream_generator/seeds.py` — иерархия зёрен: кто из какого подпотока берёт случайность. На ней держится весь детерминизм. - `src/clickstream_generator/plan.py` — план состава: кто есть в мире в - день D. Когорты, приток, двухкуковые пары и счётчики — до генерации - событий. + день D. Когорты, приток, двухкуковые пары, паспорта кук и счётчики — до + генерации событий. +- `src/clickstream_generator/weights.py` — выбор по целым весам: один приём + на весь генератор, чтобы дисциплина целочисленной случайности не жила + копиями. +- `src/clickstream_generator/reference.py` — справочники: устройства, + города, источники трафика, карта сайта. Таблицы-литералы: доля живёт в + строке, которой принадлежит. +- `src/clickstream_generator/catalog.py` — каталог товаров из + `data/catalog/products.csv`, общего у генератора и словаря ClickHouse. +- `src/clickstream_generator/day.py` — день-функция: визиты, страницы, + атрибуция, устройство и гео. Там же правила резки визитов и шов, на + который сядут торговые события. - `src/clickstream_generator/schema.py` — контракт схемы: чистые данные о колонках выгрузки. Собственность генератора; из него выводятся сам генератор, его валидация и описание выгрузки в доках. - `src/clickstream_generator/schema_doc.py` — сборка «описания выгрузки» ([`docs/formats/clickstream-event.md`](../docs/formats/clickstream-event.md)) из контракта. Документ руками не правят — пересобирают. -- `tests/` — инварианты контракта, свежесть описания и обещания плана: - чистота от зерна, приток, гарантия двухкуковых пар. +- `tests/` — инварианты контракта, свежесть описания и обещания мира: + чистота от зерна, приток, гарантия двухкуковых пар, форма суточной волны + и сборка визитов по задокументированным правилам. ## Команды diff --git a/generator/src/clickstream_generator/__init__.py b/generator/src/clickstream_generator/__init__.py index f259a83..d7d3198 100644 --- a/generator/src/clickstream_generator/__init__.py +++ b/generator/src/clickstream_generator/__init__.py @@ -3,4 +3,8 @@ Сердце пакета — контракт схемы события (`schema`): чистые данные о колонках выгрузки. Из него выводятся сам генератор, его валидация и «описание выгрузки» в доках (`schema_doc`); сторона хранилища пишется по описанию. + +Мир собирается из четырёх слоёв: числа (`world`) и справочники +(`reference`) описывают его, план состава (`plan`) говорит, кто в нём есть в +день D, а день-функция (`day`) проживает этот день событиями. """ diff --git a/generator/src/clickstream_generator/catalog.py b/generator/src/clickstream_generator/catalog.py new file mode 100644 index 0000000..e3b25ac --- /dev/null +++ b/generator/src/clickstream_generator/catalog.py @@ -0,0 +1,93 @@ +"""Каталог товаров: CSV репозитория, общий у генератора и словаря ClickHouse. + +Файл `data/catalog/products.csv` один на всех (мастер-спека, раздел 3): +генератор берёт из него имена и цены карточек, а хранилище поднимает над тем +же файлом словарь. Расхождений нет по построению — в бою так же живёт +справочник, выданный источником. + +Ассортимент — непродовольственная розница (спека генератора, раздел 9): +числа мира приняты под неё, продуктовая сеть требовала бы других — возврат +раз в неделю, корзина в двадцать позиций. + +Что решено формой файла, а не его длиной: колонки `sku,name,category,brand, +price`; артикул — четыре латинские буквы категории, дефис и четыре цифры; +цена — целые копейки (деньги генератор считает целыми, спека, раздел 2). +Строк в файле может быть сколько угодно: ни генератор, ни тесты их не +считают, а товар для карточки выбирается равномерно внутри категории. +Популярность товаров не моделируется — придумывать вес каждой строке +пришлось бы вручную, а каталог растёт механически. +""" + +import csv +from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path + +import numpy as np +from numpy.typing import NDArray + +# Путь от модуля к корню репозитория: генератор живёт в `generator/src/…`. +# Каталог не настраивается извне — он часть мира, а не запуска. +CATALOG_PATH = Path(__file__).resolve().parents[3] / "data" / "catalog" / "products.csv" + +COLUMNS = ("sku", "name", "category", "brand", "price") + + +@dataclass(frozen=True, slots=True) +class Category: + """Категория ассортимента: имя в файле, буквы артикула, кусок адреса.""" + + name: str + prefix: str + slug: str + + +CATEGORIES = ( + Category("Товары для дома", "HOME", "dlya-doma"), + Category("Текстиль", "TEXT", "tekstil"), + Category("Посуда", "POSU", "posuda"), + Category("Бытовая техника", "TECH", "tehnika"), + Category("Детские товары", "KIDS", "detskie"), + Category("Одежда и обувь", "WEAR", "odezhda"), +) + + +@dataclass(frozen=True, slots=True) +class Catalog: + """Каталог, разложенный по массивам, плюс указатель на строки категории. + + `grouped` — номера строк, сложенные по категориям подряд; `first` и + `count` говорят, где чей кусок. Так выбор товара внутри категории — + один целочисленный бросок, а порядок строк в файле ни на что не влияет. + """ + + sku: NDArray[np.object_] + name: NDArray[np.object_] + category: NDArray[np.int64] + brand: NDArray[np.object_] + price: NDArray[np.int64] + grouped: NDArray[np.int64] + first: NDArray[np.int64] + count: NDArray[np.int64] + + +@lru_cache(maxsize=1) +def catalog() -> Catalog: + """Каталог из файла; читается один раз — он часть постоянного мира.""" + with CATALOG_PATH.open(encoding="utf-8", newline="") as source: + rows = list(csv.DictReader(source)) + + index = {category.name: number for number, category in enumerate(CATEGORIES)} + 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)) + 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), + grouped=grouped, + first=np.concatenate(([0], np.cumsum(count)[:-1])), + count=count, + ) diff --git a/generator/src/clickstream_generator/day.py b/generator/src/clickstream_generator/day.py new file mode 100644 index 0000000..60d2ba5 --- /dev/null +++ b/generator/src/clickstream_generator/day.py @@ -0,0 +1,525 @@ +"""День-функция: (зерно, D) → упорядоченный поток событий модельных суток. + +Здесь трафиковая половина мира: визиты, страницы, атрибуция, устройство и +гео. Торговые события (#40) сядут на этот же поток и добавят к нему свои +строки; их колонки в pageview присутствуют, но пусты по смыслу — «пусто» +всегда пустой массив, пустая строка или 0, а не отсутствие ключа. + +День — чистая функция зерна и номера дня: одна и та же пара даёт те же +события, а день N+1 не трогает дни 1…N. Держится это на подпотоке +`Component.TRAFFIC` (спека генератора, раздел 2) и на плане состава, который +про горизонт ничего не знает. Случайность целочисленная и векторная: считать +посточно приходится ровно одно — цепочку страниц визита, где следующий шаг +зависит от предыдущего. Посточные проходы есть и кроме неё (адреса, +заголовки, IP), но там ничего не решается: numpy не умеет собирать строки, а +броски к тому времени уже сделаны — векторно и все разом. + +Модельные сутки считаются в поясе счётчика, как в выгрузке Метрики: +`EventDate` — дата в поясе счётчика, `UTCEventTime` — абсолютная метка. У +ночных событий гостей из других поясов `toDate(UTCEventTime)` ≠ `EventDate`; +сторона хранилища должна знать это заранее, иначе выведет дату сама и +разойдётся на несколько часов данных. + +**Правила резки визитов** — те же, по которым лаба сессий собирает визиты +сама и сверяет сборку с `VisitID`: + +1. Визит принадлежит одной куке: склейка `ClientID` визитом не считается. +2. Пауза дольше 30 минут рвёт визит надвое, поэтому паузы внутри визита + всегда короче таймаута, а соседние визиты куки разведены дальше него. +3. Граница модельных суток режет визит: события за полночь в дне не живут. + Исключение одно — визит с заказом, обещанным планом двухкуковых пар: его + старт сдвигается назад, чтобы воронка уместилась в сутки. Это принятое + ограничение модели: обещание плана — гарантия, ради неё мы сужаем свободу + старта. Цена названа — около 24 визитов в день из ~9,5 тыс. не начинаются + в последние минуты суток. + +**Шов для торговых событий (#40).** `Day` отдаёт, кроме колонок, два +выровненных по строкам ряда: `page` — какая это страница магазина, и +`product` — какой товар показывала карточка (−1 у прочих страниц). По ним +#40 узнаёт и то, куда вешать событие (корзина, оформление, подтверждение), +и то, что посетитель на самом деле смотрел: товар в корзине, которого никто +не открывал, — видимая глупость в воронке. Визит с назначенным заказом +всегда доходит до `/confirmation`, а перед корзиной у него всегда есть +карточка товара. Своей случайности #40 не занимает: подпоток `COMMERCE` +нетронут. +""" + +from dataclasses import dataclass +from typing import Any + +import numpy as np +from numpy.typing import NDArray + +from clickstream_generator import catalog, 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 + +_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]) + + +@dataclass(frozen=True, slots=True) +class Day: + """Поток событий одного дня: колонки выгрузки и шов для торговых событий. + + Строки упорядочены по времени — так их и проиграет проигрыватель. + `columns` — колонки контракта схемы по его порядку, все до одной; + `page` и `product` выровнены по тем же строкам (см. шов в докстринге + модуля). + """ + + day: int + columns: dict[str, NDArray[Any]] + page: NDArray[np.uint8] + product: NDArray[np.int64] + + def __len__(self) -> int: + return self.page.size + + +@dataclass(frozen=True, slots=True) +class _Visits: + """Визиты дня рядами: строка — визит, а его страницы лежат подряд. + + Где именно лежат, говорят `first` и `pages`: с какой строки начинается + визит и сколько их у него. Всё остальное решено до того, как страницы + сложились в цепочку. + """ + + cookie: NDArray[np.int64] # номер куки в дневной аудитории + ordinal: NDArray[np.int64] # какой это визит куки за день + ordering: NDArray[np.bool_] # визит с заказом, обещанным планом + source: NDArray[np.int64] + category: NDArray[np.int64] + stage: NDArray[np.int64] # докуда дошла воронка + browse: NDArray[np.int64] # страниц до воронки + pages: NDArray[np.int64] + first: NDArray[np.int64] + + def __len__(self) -> int: + return self.cookie.size + + +def stream(seed: int, day: int) -> Day: + """События дня `day` мира `seed`, упорядоченные по времени.""" + audience = plan.audience(seed, day) + rng = day_stream(seed, day, Component.TRAFFIC) + + visits = _visits(rng, audience) + page, product = _walk(rng, visits) + elapsed, duration = _elapsed(rng, visits) + start = _starts(rng, day, audience, visits, duration) + + 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())) + + rest = _columns(rng, day, audience, visits, page, product, second) + columns = { + "WatchID": watch_id, + "VisitID": visit_id[alive], + **{name: value[alive] for name, value in rest.items()}, + } + + 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], + ) + + +def _visits(rng: np.random.Generator, audience: plan.DayAudience) -> _Visits: + """Визиты дня: сколько их у каждой куки и что в каждом. + + Всё, кроме цепочки страниц: откуда пришли, за какой категорией, докуда + дойдут по воронке и сколько страниц на это уйдёт. + """ + counts = 1 + pick(rng, _VISIT_COUNT_CUMULATIVE, audience.client_id.size) + cookie = np.repeat(np.arange(counts.size, dtype=np.int64), counts) + ordinal = np.arange(cookie.size) - np.repeat(np.cumsum(counts) - counts, counts) + visits = cookie.size + + source = pick(rng, _SOURCE_CUMULATIVE, visits) + category = rng.integers(0, len(catalog.CATEGORIES), visits) + length = 1 + pick(rng, _VISIT_PAGES_CUMULATIVE, visits) + # Обещанный планом заказ достаётся первому визиту дня: слева от него + # соседей нет, поэтому двигать его внутри суток можно свободно. + ordering = audience.assigned_order[cookie] & (ordinal == 0) + stage = _funnel(rng, visits, ordering) + # Воронка удлиняет визит, а не съедает его: до корзины надо ещё дойти. + browse = np.where( + stage > 0, np.maximum(length - stage, MIN_PAGES_BEFORE_CART), length + ) + pages = browse + stage + return _Visits( + cookie=cookie, + ordinal=ordinal, + ordering=ordering, + source=source, + category=category, + stage=stage, + browse=browse, + pages=pages, + first=np.cumsum(pages) - pages, + ) + + +def _funnel( + rng: np.random.Generator, visits: int, 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) + confirmation = checkout & (draw[2] < world.CONFIRMATION_OF_CHECKOUT_PERCENT) + + stage = cart.astype(np.int64) + checkout + confirmation + # Заказ, обещанный планом, воронку проходит целиком: гарантия пар стоит + # на том, что событие покупки в этот день случится (#40 его и повесит). + stage[ordering] = len(reference.FUNNEL_PAGES) + return stage + + +def _walk( + rng: np.random.Generator, visits: _Visits +) -> tuple[NDArray[np.uint8], NDArray[np.int64]]: + """Страницы каждого визита и товар их карточек. + + Единственное место дня, где посточен сам расчёт: следующая страница + зависит от предыдущей, векторно такую цепочку не сложить. Броски + заготовлены заранее и целиком — в цикле остаётся ходить по таблицам. + """ + total = int(visits.pages.sum()) + page = np.empty(total, dtype=np.uint8) + page[visits.first] = _ENTRY_PAGE[visits.source, rng.integers(0, 100, len(visits))] + step = rng.integers(0, 100, total) + funnel = np.array(reference.FUNNEL_PAGES, dtype=np.uint8) + + for visit in range(len(visits)): + begin, browsed = int(visits.first[visit]), int(visits.browse[visit]) + for row in range(begin + 1, begin + browsed): + previous = page[row - 1] + table = _NEXT_FROM_PRODUCT if previous == Page.PRODUCT else _NEXT_FROM_LIST + 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] + + goods = catalog.catalog() + shown = page == Page.PRODUCT + row_category = np.repeat(visits.category, visits.pages)[shown] + # Товар — равномерно внутри категории визита: популярность строк не + # моделируется, каталог дорастает механически (см. `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] + return page, product + + +def _elapsed( + rng: np.random.Generator, visits: _Visits +) -> tuple[NDArray[np.int64], NDArray[np.int64]]: + """Секунды каждой страницы от начала своего визита и длина визитов.""" + total = int(visits.pages.sum()) + short = rng.integers(*world.PAGE_PAUSE_SECONDS, total) + long = rng.integers(*world.LONG_PAUSE_SECONDS, total) + thinking = rng.integers(0, 100, total) < world.LONG_PAUSE_PERCENT + pause = np.where(thinking, long, short) + + pause[visits.first] = 0 + elapsed = np.cumsum(pause) + elapsed -= np.repeat(elapsed[visits.first], visits.pages) + return elapsed, elapsed[visits.first + visits.pages - 1] + + +def _starts( + rng: np.random.Generator, + day: int, + audience: plan.DayAudience, + visits: _Visits, + duration: NDArray[np.int64], +) -> NDArray[np.int64]: + """Секунда начала каждого визита внутри модельных суток. + + Волна задана в местном времени посетителя, а сутки считаются в поясе + счётчика: гостю из другого пояса профиль поворачивается на разницу. + Пик от этого слегка размазывается — как в жизни. + """ + 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) + 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, + ) + return start + + +def _columns( + rng: np.random.Generator, + day: int, + audience: plan.DayAudience, + visits: _Visits, + page: NDArray[np.uint8], + product: NDArray[np.int64], + second: NDArray[np.int64], +) -> dict[str, NDArray[Any]]: + """Колонки выгрузки по строкам-страницам — все, что решил контракт схемы. + + Торговые колонки заполнены пустотой своего типа: пустым массивом и + пустой строкой. Ключ есть всегда — потребитель не должен гадать, была + колонка или её забыли. + """ + total = page.size + sources = reference.TRAFFIC_SOURCES + source, pages, first = visits.source, visits.pages, visits.first + device = audience.device[visits.cookie] + city = audience.city[visits.cookie] + + url, title = _addresses(rng, visits, page, product) + # Метки перехода живут и в адресе входа, как в жизни: разбор такого + # адреса — материал лабы, а колонки UTM рядом дают ей эталон. + url[first] = [ + f"{address}{'&' if '?' in address else '?'}{query}" if query else address + for address, query in zip(url[first], _UTM_QUERY[source], strict=True) + ] + referer = np.empty(total, dtype=object) + referer[1:], referer[0] = url[:-1], "" + referer[first] = _of(sources, "referer")[source] + + def by_visit(values: NDArray[Any]) -> NDArray[Any]: + return np.repeat(values, pages) + + def by_source(field: str, dtype: Any = object) -> NDArray[Any]: + return by_visit(_of(sources, field, dtype)[source]) + + def by_device(field: str, dtype: Any = object) -> NDArray[Any]: + return by_visit(_of(reference.DEVICE_PROFILES, field, dtype)[device]) + + def by_city(field: str, dtype: Any = object) -> NDArray[Any]: + return by_visit(_of(reference.CITIES, field, dtype)[city]) + + # Дата дня — в поясе счётчика, а полночь того же дня — метка UTC, от + # которой отсчитываются секунды: между ними ровно смещение пояса. + date = np.datetime64(world.ORIGIN, "D") + np.timedelta64(day, "D") + midnight = np.datetime64(world.ORIGIN, "s") + np.timedelta64(day, "D") + away = np.timedelta64(world.COUNTER_TIMEZONE_MINUTES, "m") + yclid = np.where( + _of(sources, "has_yclid", bool)[source], + rng.integers(1, YCLID_LIMIT, source.size, dtype=np.uint64), + 0, + ).astype(np.uint64) + return { + "ClientID": by_visit(audience.client_id[visits.cookie]), + "CounterID": np.full(total, world.COUNTER_ID, dtype=np.uint32), + "EventDate": np.full(total, date, dtype="datetime64[D]"), + "UTCEventTime": midnight - away + second.astype("timedelta64[s]"), + "ClientTimeZone": by_city("timezone_minutes", np.int16), + "EventType": np.full(total, "pageview", dtype=object), + "Sign": np.ones(total, dtype=np.int8), + "URL": url, + "Referer": referer, + "Title": title, + "UTMSource": by_source("utm_source"), + "UTMMedium": by_source("utm_medium"), + "UTMCampaign": by_source("utm_campaign"), + "UTMContent": by_source("utm_content"), + "UTMTerm": by_source("utm_term"), + "LastTrafficSource": by_source("last_traffic_source"), + "HasGCLID": by_source("has_gclid", np.uint8), + "YCLID": by_visit(yclid), + "Browser": by_device("browser"), + "BrowserMajorVersion": by_device("browser_major_version", np.uint16), + "BrowserLanguage": by_device("language"), + "OperatingSystem": by_device("operating_system"), + "OperatingSystemRoot": by_device("operating_system_root"), + "DeviceCategory": by_device("category", np.uint8), + "MobilePhoneModel": by_device("phone_model"), + "ScreenWidth": by_device("screen_width", np.uint16), + "ScreenHeight": by_device("screen_height", np.uint16), + "IPAddress": by_visit(_ip_addresses(rng, city, device)), + "RegionCountry": np.full(total, reference.COUNTRY_NAME, dtype=object), + "RegionCity": by_city("name"), + "RegionCountryID": np.full(total, reference.COUNTRY_REGION_ID, dtype=np.uint32), + "RegionCityID": by_city("region_id", np.uint32), + # Цели дублируют торговые события, поэтому их ставит #40. + "GoalsReached": _blank(total, "uint32"), + # Своих параметров сайт стенда пока не шлёт: вариант A/B-теста был бы + # постоянной куки, а не поведением дня. Решение отложено, не забыто: + # колонку заполнит #47. + "ParsedParamsKey1": _blank(total, object), + "purchaseID": _blank(total, object), + "purchaseRevenue": _blank(total, "float64"), + "purchaseCurrency": _blank(total, object), + "purchaseCoupon": _blank(total, object), + "productID": _blank(total, object), + "productName": _blank(total, object), + "productCategory": _blank(total, object), + "productPrice": _blank(total, "int64"), + "productQuantity": _blank(total, "uint64"), + "productEventType": _blank(total, object), + "ecommerce": np.full(total, "", dtype=object), + } + + +def _addresses( + rng: np.random.Generator, + visits: _Visits, + page: NDArray[np.uint8], + product: NDArray[np.int64], +) -> tuple[NDArray[np.object_], NDArray[np.object_]]: + """Адрес и заголовок каждой страницы; у входа в адресе живут метки UTM.""" + total = page.size + goods = catalog.catalog() + row_category = np.repeat(visits.category, visits.pages) + query = rng.integers(0, len(reference.SEARCH_QUERIES), total) + static = rng.integers(0, len(reference.STATIC_PAGES), total) + + url: list[str] = [] + title: list[str] = [] + for row in range(total): + kind = page[row] + if kind == Page.PRODUCT: + number = product[row] + path, heading = f"/product/{goods.sku[number]}", goods.name[number] + elif kind == Page.CATALOG: + group = catalog.CATEGORIES[row_category[row]] + path, heading = f"/catalog/{group.slug}", group.name + elif kind == Page.SEARCH: + text = reference.SEARCH_QUERIES[query[row]] + path, heading = f"/search?text={text}", "Поиск по магазину" + elif kind == Page.STATIC: + path, heading = reference.STATIC_PAGES[static[row]] + else: + path, heading = _FIXED_PAGES[kind] + url.append(reference.SITE_URL + path) + title.append(f"{heading} — {reference.SITE_NAME}") + return np.array(url, dtype=object), np.array(title, dtype=object) + + +def _ip_addresses( + rng: np.random.Generator, city: NDArray[np.int64], device: NDArray[np.int64] +) -> NDArray[np.object_]: + """Адрес визита: городской ломоть, а у телефонов — CGNAT оператора.""" + count = city.size + prefix = _of(reference.CITIES, "ip_prefix")[city] + category = _of(reference.DEVICE_PROFILES, "category", np.int64)[device] + phone = category == reference.PHONE_CATEGORY + operator = rng.integers(*reference.MOBILE_IP_SECOND_BYTE, count) + block = rng.integers(0, 256, count) + host = rng.integers(1, 255, count) + + first_byte = reference.MOBILE_IP_FIRST_BYTE + return np.array( + [ + f"{first_byte}.{operator[visit]}.{block[visit]}.{host[visit]}" + if phone[visit] + else f"{prefix[visit]}{host[visit]}" + for visit in range(count) + ], + dtype=object, + ) + + +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 — понедельник. + weekend = day % 7 >= 5 + profile = np.array( + world.WEEKEND_HOURS_PERCENT if weekend else world.WEEKDAY_HOURS_PERCENT + ) + shifts = [ + (city.timezone_minutes - world.COUNTER_TIMEZONE_MINUTES) // 60 + for city in reference.CITIES + ] + return np.cumsum([np.roll(profile, -shift) for shift in shifts], axis=1) + + +def _of(table: tuple[Any, ...], field: str, dtype: Any = object) -> NDArray[Any]: + """Колонка справочника массивом: строка выбирается своим номером.""" + return np.array([getattr(row, field) for row in table], dtype=dtype) + + +def _blank(size: int, dtype: Any) -> NDArray[np.object_]: + """Колонка-массив, пустая по смыслу: у каждой строки пустой массив.""" + empty = np.array([], dtype=dtype) + empty.flags.writeable = False + column = np.empty(size, dtype=object) + column.fill(empty) + return column + + +def _hundred(percent: tuple[int, ...]) -> NDArray[np.uint8]: + """Сотня ячеек «бросок 0…99 → страница»: выбор одним обращением.""" + return np.repeat(np.array(reference.BROWSE_PAGES, dtype=np.uint8), percent) + + +def _utm_query(source: reference.TrafficSource) -> str: + """Метки перехода строкой запроса; у источника без меток — пустая.""" + marks = ((field, getattr(source, f"utm_{field}")) for field in _UTM_FIELDS) + return "&".join(f"utm_{field}={value}" for field, value in marks if value) + + +_UTM_FIELDS = ("source", "medium", "campaign", "content", "term") +_UTM_QUERY = np.array( + [_utm_query(source) for source in reference.TRAFFIC_SOURCES], dtype=object +) + +_FIXED_PAGES = { + Page.HOME: ("/", "Интернет-магазин товаров для дома"), + Page.CART: ("/cart", "Корзина"), + Page.CHECKOUT: ("/checkout", "Оформление заказа"), + Page.CONFIRMATION: ("/confirmation", "Заказ оформлен"), +} + +_ENTRY_PAGE = np.array( + [_hundred(source.entry_percent) for source in reference.TRAFFIC_SOURCES] +) +_NEXT_FROM_LIST = _hundred(reference.FROM_LISTING_PERCENT) +_NEXT_FROM_PRODUCT = _hundred(reference.FROM_PRODUCT_PERCENT) + +# Потолок id клика Директа: тринадцать цифр, как у настоящих меток. +YCLID_LIMIT = 10**13 diff --git a/generator/src/clickstream_generator/plan.py b/generator/src/clickstream_generator/plan.py index d66d5dd..1e115c1 100644 --- a/generator/src/clickstream_generator/plan.py +++ b/generator/src/clickstream_generator/plan.py @@ -12,8 +12,14 @@ - приток — кто и когда впервые появился, и сколько раз вернётся; - двухкуковые пары — какой человек завёл вторую куку и в какие дни каждая из двух кук обязана оформить заказ; +- паспорт куки — устройство и город: они у куки одни и те же во всех её + днях, а знает об этом только план (у пары один город на двоих); - счётчики — приток по дням, дневная и накопленная аудитория, пары. +Единица дня здесь — день активности куки, а не визит: слово «визит» +закреплено за сессией и полем `VisitID`, и визитов у куки за день бывает +несколько. Сколько именно — решает день-функция, плану это безразлично. + Случайность тянется целыми числами: диапазоны и выбор по целым весам. Плавающие распределения системной математики не зовутся — они расходятся между версиями numpy и архитектурами CPU, а обещано побайтовое совпадение @@ -26,8 +32,9 @@ from functools import lru_cache import numpy as np from numpy.typing import NDArray -from clickstream_generator import world +from clickstream_generator import reference, world from clickstream_generator.seeds import cohort_stream +from clickstream_generator.weights import pick # Куки живут числами ниже 2^53: выше JSON округляет — тот же довод, что у # `WatchID` в контракте схемы. Граница не достигается: 2^53 сам уже за ней. @@ -37,18 +44,39 @@ CLIENT_ID_LIMIT = 2**53 # долю общего веса. _RETURN_COUNT_CUMULATIVE = np.cumsum(world.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( + [profile.weight for profile in reference.DEVICE_PROFILES] +) + +# Профили, разложенные надвое — телефоны и всё остальное. Вторая кука пары +# берётся из другой половины: у человека это второе устройство, а не копия +# первого. Мастер-спека (раздел 5) называет пару «телефон и ноутбук»; у нас +# к телефону встаёт десктоп или планшет — вторым устройством бывает и он, а +# запрет на планшет не дал бы ничего, кроме зауженного справочника. +_IS_PHONE = np.array( + [ + profile.category == reference.PHONE_CATEGORY + for profile in reference.DEVICE_PROFILES + ] +) +_DEVICE_HALVES = (np.flatnonzero(~_IS_PHONE), np.flatnonzero(_IS_PHONE)) +_DEVICE_HALF_CUMULATIVE = tuple( + np.cumsum([reference.DEVICE_PROFILES[number].weight for number in half]) + for half in _DEVICE_HALVES +) @dataclass(frozen=True, slots=True) class Cohort: - """Люди, впервые пришедшие в мир в день `day`, с их куками и визитами. + """Люди, впервые пришедшие в мир в день `day`, с их куками и днями. Куки лежат одним рядом: сначала первые куки людей — по одной на человека, индексы 0…`people`−1, — затем вторые куки двухкуковых пар. Кука без пары и есть человек целиком. - Визиты — плоская таблица «кука — день», отсортированная и без повторов: - на день у куки приходится не больше одного визита. Все дни лежат в окне + Дни активности — плоская таблица «кука — день», отсортированная и без + повторов: дважды за день кука не появляется. Все дни лежат в окне активности человека: от `day` до `day` + хвост возвратов. """ @@ -57,11 +85,14 @@ class Cohort: client_id: NDArray[np.uint64] birth_day: NDArray[np.int64] buyer: NDArray[np.bool_] - visit_cookie: NDArray[np.int64] - visit_day: NDArray[np.int64] + active_cookie: NDArray[np.int64] + active_day: NDArray[np.int64] # Пары и назначенные им заказы — по строке на пару, по колонке на куку. pair_cookies: NDArray[np.int64] pair_order_days: NDArray[np.int64] + # Паспорт куки: номер профиля устройства и номер города в справочниках. + device: NDArray[np.int64] + city: NDArray[np.int64] def __post_init__(self) -> None: """Когорта запоминается, поэтому массивы отдаются только на чтение. @@ -87,28 +118,36 @@ class Cohort: """Сколько пар получили назначенные заказы.""" return len(self.pair_cookies) - def visitors_on( - self, day: int - ) -> tuple[NDArray[np.uint64], NDArray[np.bool_], NDArray[np.bool_]]: - """Кто из когорты пришёл в день `day`: куки, покупатели, заказы пар. + def visitors_on(self, day: int) -> DayAudience: + """Кто из когорты пришёл в день `day` — её доля дневной аудитории. - Как визиты и пары уложены в массивы, знает только когорта: снаружи - спрашивают день и получают три ряда одной длины. + Как дни активности и пары уложены в массивы, знает только когорта: + снаружи спрашивают день и получают готовые ряды одной длины. """ - here = self.visit_cookie[self.visit_day == day] + here = self.active_cookie[self.active_day == day] ordering = self.pair_cookies[self.pair_order_days == day] - return self.client_id[here], self.buyer[here], np.isin(here, ordering) + return DayAudience( + day=day, + client_id=self.client_id[here], + buyer=self.buyer[here], + assigned_order=np.isin(here, ordering), + device=self.device[here], + city=self.city[here], + ) @dataclass(frozen=True, slots=True) class DayAudience: - """Куки, пришедшие в день `day`, — вход для будущей дня-функции.""" + """Куки, пришедшие в день `day`, — вход дня-функции.""" day: int client_id: NDArray[np.uint64] buyer: NDArray[np.bool_] # Куки, которым план назначил на этот день гарантированный заказ пары. assigned_order: NDArray[np.bool_] + # Паспорт куки: номера строк в справочниках устройств и городов. + device: NDArray[np.int64] + city: NDArray[np.int64] @dataclass(frozen=True, slots=True) @@ -148,16 +187,18 @@ def cohort(seed: int, day: int) -> Cohort: # Вторая кука рождается, пока человек ещё ходит: тем же затухающим # профилем, что и возвраты, — обычно через дни, изредка через месяцы. # Фиксированного зазора нет, иначе пары в данных узнавались бы по нему. - birth_day[people:] += 1 + _pick(rng, _RETURN_DELAY_CUMULATIVE, paired.size) + birth_day[people:] += 1 + pick(rng, _RETURN_DELAY_CUMULATIVE, paired.size) - visit_cookie, visit_day = _visits(rng, birth_day, day + world.RETURN_TAIL_DAYS) - pair_cookies, pair_order_days = _assign_orders( - rng, - np.column_stack((paired, np.arange(people, cookies, dtype=np.int64))), - visit_cookie, - visit_day, - cookies, + active_cookie, active_day = _active_days( + rng, birth_day, day + world.RETURN_TAIL_DAYS ) + twins = np.column_stack((paired, np.arange(people, cookies, dtype=np.int64))) + pair_cookies, pair_order_days = _assign_orders( + rng, twins, active_cookie, active_day, cookies + ) + # Паспорт бросается последним — после всего, что уже измерено: тогда + # счётчики канонического мира от этой добавки не двигаются. + device, city = _passports(rng, twins, cookies) return Cohort( day=day, people=people, @@ -165,10 +206,12 @@ def cohort(seed: int, day: int) -> Cohort: birth_day=birth_day, # Вторая кука принадлежит покупателю — как и первая кука его пары. buyer=np.concatenate((buyer, np.ones(paired.size, dtype=bool))), - visit_cookie=visit_cookie, - visit_day=visit_day, + active_cookie=active_cookie, + active_day=active_day, pair_cookies=pair_cookies, pair_order_days=pair_order_days, + device=device, + city=city, ) @@ -177,18 +220,18 @@ def audience(seed: int, day: int) -> DayAudience: if day < 0: raise ValueError(f"события начинаются в D0: дня {day} на оси нет") - client_id, buyer, assigned = [], [], [] # Предыстория ровно такой глубины, чтобы окна хватило и первому дню оси. - for born in range(day - world.RETURN_TAIL_DAYS, day + 1): - came, bought, ordered = cohort(seed, born).visitors_on(day) - client_id.append(came) - buyer.append(bought) - assigned.append(ordered) + parts = [ + cohort(seed, born).visitors_on(day) + for born in range(day - world.RETURN_TAIL_DAYS, day + 1) + ] return DayAudience( day=day, - client_id=np.concatenate(client_id), - buyer=np.concatenate(buyer), - assigned_order=np.concatenate(assigned), + client_id=np.concatenate([part.client_id for part in parts]), + buyer=np.concatenate([part.buyer for part in parts]), + assigned_order=np.concatenate([part.assigned_order for part in parts]), + device=np.concatenate([part.device for part in parts]), + city=np.concatenate([part.city for part in parts]), ) @@ -202,9 +245,9 @@ def counters(seed: int, days: int) -> PlanCounters: # Ниже — вся предыстория: её когорты ещё возвращаются в горизонт. for born in range(-world.RETURN_TAIL_DAYS, days): born_cohort = cohort(seed, born) - inside = (born_cohort.visit_day >= 0) & (born_cohort.visit_day < days) - daily += np.bincount(born_cohort.visit_day[inside], minlength=days) - seen.append(born_cohort.client_id[np.unique(born_cohort.visit_cookie[inside])]) + inside = (born_cohort.active_day >= 0) & (born_cohort.active_day < days) + daily += np.bincount(born_cohort.active_day[inside], minlength=days) + seen.append(born_cohort.client_id[np.unique(born_cohort.active_cookie[inside])]) appeared = born_cohort.birth_day[ (born_cohort.birth_day >= 0) & (born_cohort.birth_day < days) ] @@ -227,17 +270,17 @@ def _influx(rng: np.random.Generator, day: int) -> int: return base + int(rng.integers(-spread, spread + 1)) -def _visits( +def _active_days( rng: np.random.Generator, birth_day: NDArray[np.int64], window_end: int ) -> tuple[NDArray[np.int64], NDArray[np.int64]]: - """Дни визитов каждой куки: день рождения и возвраты, пока окно открыто.""" + """Дни активности каждой куки: день рождения и возвраты, пока окно открыто.""" 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())) + returns[returning] = 1 + pick(rng, _RETURN_COUNT_CUMULATIVE, int(returning.sum())) owner = np.repeat(np.arange(cookies, dtype=np.int64), returns) - delay = 1 + _pick(rng, _RETURN_DELAY_CUMULATIVE, owner.size) + delay = 1 + pick(rng, _RETURN_DELAY_CUMULATIVE, owner.size) cookie = np.concatenate((np.arange(cookies, dtype=np.int64), owner)) when = np.concatenate((birth_day, birth_day[owner] + delay)) @@ -247,7 +290,7 @@ def _visits( order = np.lexsort((when, cookie)) cookie, when = cookie[order], when[order] - # Два возврата в один день — один визит: день у куки бывает только один. + # Два возврата в один день — один день активности: он у куки бывает один. first_of_day = np.ones(cookie.size, dtype=bool) first_of_day[1:] = (cookie[1:] != cookie[:-1]) | (when[1:] != when[:-1]) return cookie[first_of_day], when[first_of_day] @@ -256,32 +299,49 @@ def _visits( def _assign_orders( rng: np.random.Generator, pair_cookies: NDArray[np.int64], - visit_cookie: NDArray[np.int64], - visit_day: NDArray[np.int64], + active_cookie: NDArray[np.int64], + active_day: NDArray[np.int64], cookies: int, ) -> tuple[NDArray[np.int64], NDArray[np.int64]]: - """Дни гарантированных заказов пары: по визиту каждой из двух кук, от D0. + """Дни гарантированных заказов пары: по дню каждой из двух кук, от D0. - Человеку предыстории, у чьей куки визитов на оси не осталось, пара не + Человеку предыстории, у чьей куки дней на оси не осталось, пара не назначается: обещать заказ, которого никто не увидит, нечестно. Дни берутся той же случайностью, что и всё остальное, — в данных условность не видна. """ - on_axis = visit_day >= 0 - counts = np.bincount(visit_cookie[on_axis], minlength=cookies) - # Визиты отсортированы по куке, а внутри куки — по дню, и дни от D0 идут - # последними. Значит, дни на оси у куки — хвост её блока: от конца блока - # назад ровно `counts` визитов. - first_on_axis = np.searchsorted(visit_cookie, np.arange(cookies), "right") - counts + on_axis = active_day >= 0 + counts = np.bincount(active_cookie[on_axis], minlength=cookies) + # Дни отсортированы по куке, а внутри куки — по возрастанию, и дни от D0 + # идут последними. Значит, дни на оси у куки — хвост её блока: от конца + # блока назад ровно `counts` дней. + first_on_axis = np.searchsorted(active_cookie, np.arange(cookies), "right") - counts assigned = np.all(counts[pair_cookies] > 0, axis=1) pairs = pair_cookies[assigned] chosen = first_on_axis[pairs] + rng.integers(0, counts[pairs]) - return pairs, visit_day[chosen] + return pairs, active_day[chosen] -def _pick( - rng: np.random.Generator, cumulative: NDArray[np.int64], size: int -) -> NDArray[np.int64]: - """Выбор по целым весам: куда попал бросок в общий вес, тот вариант и вышел.""" - return np.searchsorted(cumulative, rng.integers(0, cumulative[-1], size), "right") +def _passports( + rng: np.random.Generator, twins: NDArray[np.int64], cookies: int +) -> tuple[NDArray[np.int64], NDArray[np.int64]]: + """Устройство и город каждой куки; у пары город один, устройства разные. + + Выводить паспорт арифметикой из `ClientID` было бы дешевле, но про + двухкуковые пары знает только план, а два города у одного человека — + ложь в данных (спека генератора, раздел 9). + """ + device = pick(rng, _DEVICE_CUMULATIVE, cookies) + city = pick(rng, _CITY_CUMULATIVE, cookies) + + first, second = twins[:, 0], twins[:, 1] + city[second] = city[first] + # Половина справочника выбирается по первой куке, строка в ней — броском. + other_half = np.where(_IS_PHONE[device[first]], 0, 1) + for half in (0, 1): + here = second[other_half == half] + device[here] = _DEVICE_HALVES[half][ + pick(rng, _DEVICE_HALF_CUMULATIVE[half], here.size) + ] + return device, city diff --git a/generator/src/clickstream_generator/reference.py b/generator/src/clickstream_generator/reference.py new file mode 100644 index 0000000..a7ac578 --- /dev/null +++ b/generator/src/clickstream_generator/reference.py @@ -0,0 +1,252 @@ +"""Справочники мира: устройства, города, источники трафика, карта сайта. + +Таблицы-литералы, а не посточный фейкер (спека генератора, разделы 6 и 9): +нам нужны не случайные строки, а связки — телефон тянет за собой Safari, iOS +и размер экрана; город тянет id региона и часовой пояс. Фейкер связок не +даёт, таблицу пришлось бы написать всё равно, а новая зависимость молча +меняла бы мир при обновлении своих словарей. + +Веса долей живут в самих строках: доля неотделима от строки, которой она +принадлежит. Числа, ни к какой строке не привязанные — суточная волна, длина +визита, воронка, — лежат в `world.py`. + +Настоящее здесь одно — гео-идентификаторы (оговорка в `City`). Магазин, +сайты-рефереры и адреса вымышлены: домен `example.com` отведён под примеры +RFC 2606, адреса нероутируемы. + +Таблицы записаны руками (`fmt: off`): строка справочника — строка таблицы, +а не десять строк исходника. +""" + +from dataclasses import dataclass +from enum import IntEnum +from urllib.parse import quote + +# Магазин стенда: вымышленный, домен из зарезервированных под примеры. +SITE_NAME = "Дом и уют" +SITE_URL = "https://shop.example.com" + + +class Page(IntEnum): + """Страницы магазина: пять для блуждания, три для воронки заказа.""" + + HOME = 0 + CATALOG = 1 + SEARCH = 2 + PRODUCT = 3 + STATIC = 4 + CART = 5 + CHECKOUT = 6 + CONFIRMATION = 7 + + +# Страницы блуждания: их порядок — порядок колонок в таблицах переходов ниже. +BROWSE_PAGES = (Page.HOME, Page.CATALOG, Page.SEARCH, Page.PRODUCT, Page.STATIC) + +# Хвост воронки по порядку прохождения: сколько шагов пройдено — то и есть +# «докуда дошёл визит». +FUNNEL_PAGES = (Page.CART, Page.CHECKOUT, Page.CONFIRMATION) + +# Куда посетитель уходит со страницы, проценты по колонкам `BROWSE_PAGES`. +# Правил всего два: с карточки товара идут смотреть соседние карточки и +# каталог, с любой листающей страницы — в карточку. +FROM_PRODUCT_PERCENT = (9, 30, 8, 45, 8) +FROM_LISTING_PERCENT = (6, 12, 6, 70, 6) + +# Статические страницы: адрес и заголовок. +STATIC_PAGES = ( + ("/delivery", "Доставка и оплата"), + ("/about", "О магазине"), + ("/contacts", "Контакты"), +) + +# Запросы к поиску по сайту. В адресе они лежат процентными кодами — так их +# отдаёт и выгрузка, и разбор такого адреса сам по себе материал лабы. +SEARCH_QUERIES = tuple( + quote(text) + for text in ( + "полотенца", + "сковорода", + "постельное бельё", + "детский стульчик", + "электрочайник", + "шторы", + "набор кастрюль", + "тапочки", + ) +) + + +# Коды типа устройства у Метрики: 1 — десктоп, 2 — телефон, 3 — планшет, +# 4 — телевизор. Телефон назван отдельно: по нему различаются и мобильный +# адрес, и вторая кука пары — «телефон и ноутбук» (мастер-спека, раздел 5). +PHONE_CATEGORY = 2 + + +@dataclass(frozen=True, slots=True) +class DeviceProfile: + """Устройство посетителя целиком: браузер, ОС, экран. + + Это паспорт куки: кука — браузер на устройстве, поэтому во всех её + визитах профиль один и тот же (спека генератора, раздел 9). + """ + + weight: int + category: int # коды Метрики, см. `PHONE_CATEGORY` + browser: str + browser_major_version: int + language: str + operating_system: str + operating_system_root: str + phone_model: str + screen_width: int + screen_height: int + + +# Доли устройств — правдоподобная российская розница: мобильных около двух +# третей, десктоп треть, планшеты тонкой полосой. +# fmt: off +DEVICE_PROFILES = ( + DeviceProfile(14, 1, "Chrome", 131, "ru", + "Windows 10", "Windows", "", 1920, 1080), + DeviceProfile(6, 1, "Chrome", 131, "ru", + "Windows 11", "Windows", "", 1366, 768), + DeviceProfile(6, 1, "YandexBrowser", 24, "ru", + "Windows 10", "Windows", "", 1920, 1080), + DeviceProfile(3, 1, "Safari", 17, "ru", + "macOS 14", "macOS", "", 1440, 900), + DeviceProfile(2, 1, "Firefox", 133, "ru", + "Windows 10", "Windows", "", 1600, 900), + DeviceProfile(1, 1, "Chrome", 131, "en", + "Ubuntu 24.04", "Linux", "", 1920, 1080), + DeviceProfile(10, 2, "Safari", 17, "ru", + "iOS 17.4", "iOS", "iPhone 14", 390, 844), + DeviceProfile(6, 2, "Safari", 16, "ru", + "iOS 16.6", "iOS", "iPhone 12", 390, 844), + DeviceProfile(13, 2, "Chrome", 131, "ru", + "Android 14", "Android", "Galaxy A53", 412, 915), + DeviceProfile(12, 2, "Chrome", 130, "ru", + "Android 13", "Android", "Redmi Note 12", 393, 873), + DeviceProfile(9, 2, "YandexBrowser", 24, "ru", + "Android 13", "Android", "Honor X8", 360, 780), + DeviceProfile(6, 2, "Samsung Internet", 26, "ru", + "Android 14", "Android", "Galaxy S23", 360, 780), + DeviceProfile(5, 2, "Chrome", 131, "ru", + "Android 12", "Android", "Vivo Y21", 360, 800), + DeviceProfile(4, 3, "Safari", 17, "ru", + "iPadOS 17.4", "iOS", "", 810, 1080), + DeviceProfile(3, 3, "Chrome", 130, "ru", + "Android 13", "Android", "", 800, 1280), +) +# fmt: on + + +@dataclass(frozen=True, slots=True) +class City: + """Город посетителя: id региона, часовой пояс, ломоть адресов. + + Гео-идентификаторы — настоящие числа геобазы Яндекса, а не выдуманные: + каждый проверен 2026-08-02 обращением к живым сервисам Яндекса по этому + же номеру (`yandex.ru/pogoda/`, `yandex.ru/maps/225/russia/`) — + страница открывает ожидаемое место. Оговорка честная: опубликованной + таблицы геобазы найти не удалось, а что `RegionCityID` Метрики нумерует + регионы той же геобазой — обоснованное допущение, а не подтверждённый + источником факт. Часовые пояса — из Википедии; часы в России не + переводят с 2014 года, поэтому смещение постоянное. + + Адреса нероутируемые (спека генератора, раздел 9): городу отводится + ломоть документационных сетей RFC 5737 или benchmark-сети 198.18/15. + Правдоподобные публичные адреса принадлежат живым организациям, и в + учебных данных им не место. + """ + + weight: int + name: str # по-английски, как в выгрузке Метрики + region_id: int + timezone_minutes: int + ip_prefix: str + + +COUNTRY_NAME = "RU" +COUNTRY_REGION_ID = 225 + +# Регион присутствия — Поволжье с центром в Самаре: свой миллионник, города +# своего региона и тонкий хвост остальной страны (спека генератора, +# раздел 9). «Топ городов России» дал бы магазину с одним складом карту, +# которой у него быть не может. +CITIES = ( + City(34, "Samara", 51, 240, "192.0.2."), + City(12, "Tolyatti", 240, 240, "198.51.100."), + City(5, "Syzran", 11139, 240, "203.0.113."), + City(7, "Ulyanovsk", 195, 240, "198.18.0."), + City(7, "Saratov", 194, 240, "198.18.1."), + City(5, "Penza", 49, 180, "198.18.2."), + City(5, "Kazan", 43, 180, "198.18.3."), + City(4, "Ufa", 172, 300, "198.18.4."), + City(4, "Orenburg", 48, 300, "198.18.5."), + City(3, "Nizhny Novgorod", 47, 180, "198.18.6."), + City(2, "Volgograd", 38, 180, "198.18.7."), + City(5, "Moscow", 213, 180, "198.18.8."), + City(3, "Saint Petersburg", 2, 180, "198.18.9."), + City(2, "Yekaterinburg", 54, 300, "198.18.10."), + City(1, "Novosibirsk", 65, 420, "198.18.11."), + City(1, "Krasnodar", 35, 180, "198.18.12."), +) + +# Мобильный интернет: операторы раздают телефонам адреса CGNAT-диапазона +# 100.64/10 — второй байт от 64 до 127. Город по такому адресу не читается, +# как и в жизни. +MOBILE_IP_FIRST_BYTE = 100 +MOBILE_IP_SECOND_BYTE = (64, 128) + + +@dataclass(frozen=True, slots=True) +class TrafficSource: + """Откуда посетитель пришёл: реферер, метки перехода, вход на сайт. + + Метки кликов отданы колонками `HasGCLID` и `YCLID`: их выгрузка + разбирает за нас. UTM остаются и в адресе входа — как в жизни. + """ + + weight: int + last_traffic_source: str + referer: str + utm_source: str + utm_medium: str + utm_campaign: str + utm_content: str + utm_term: str + has_gclid: int + has_yclid: bool + entry_percent: tuple[int, ...] # вход на сайт по колонкам `BROWSE_PAGES` + + +# fmt: off +TRAFFIC_SOURCES = ( + TrafficSource(30, "organic", "https://yandex.ru/search/", + "", "", "", "", "", 0, False, (10, 30, 5, 50, 5)), + TrafficSource(9, "organic", "https://www.google.com/", + "", "", "", "", "", 0, False, (10, 30, 5, 50, 5)), + TrafficSource(22, "direct", "", + "", "", "", "", "", 0, False, (55, 15, 5, 15, 10)), + TrafficSource(8, "ad", "https://yandex.ru/", + "yandex", "cpc", "posuda-poisk", "text-1", "kupit-skovorodu", + 0, True, (5, 45, 0, 50, 0)), + TrafficSource(6, "ad", "https://an.yandex.ru/", + "yandex", "cpc", "tekstil-rsya", "banner-2", "", + 0, True, (5, 45, 0, 50, 0)), + TrafficSource(5, "ad", "https://www.google.com/", + "google", "cpc", "home-shopping", "ad-1", "postelnoe-belyo", + 1, False, (5, 45, 0, 50, 0)), + TrafficSource(7, "social", "https://vk.com/", + "vk", "social", "vk-lenta", "post-3", "", + 0, False, (15, 25, 0, 55, 5)), + TrafficSource(5, "email", "", + "email", "email", "nedelnaya-rassylka", "blok-1", "", + 0, False, (10, 40, 0, 45, 5)), + TrafficSource(5, "referral", "https://market.example.com/", + "", "", "", "", "", 0, False, (5, 25, 0, 65, 5)), + TrafficSource(3, "recommend", "https://dzen.ru/", + "", "", "", "", "", 0, False, (10, 30, 0, 55, 5)), +) +# fmt: on diff --git a/generator/src/clickstream_generator/schema.py b/generator/src/clickstream_generator/schema.py index 93f56ef..d035bb0 100644 --- a/generator/src/clickstream_generator/schema.py +++ b/generator/src/clickstream_generator/schema.py @@ -100,7 +100,9 @@ COLUMNS: tuple[Column, ...] = ( numpy_dtype="datetime64[D]", normalized_name="event_date", group=ColumnGroup.IDENTIFIERS, - comment="дата события; по ней режется партиция", + comment="дата события в часовом поясе счётчика; по ней режется" + " партиция. Дату из `UTCEventTime` не выводить: у ночных событий" + " она на сутки другая", ), Column( name="UTCEventTime", @@ -108,7 +110,9 @@ COLUMNS: tuple[Column, ...] = ( numpy_dtype="datetime64[s]", normalized_name="utc_event_time", group=ColumnGroup.IDENTIFIERS, - comment="время события в UTC — единственная метка времени, как у Метрики", + comment="время события в UTC — единственная метка времени, как у" + " Метрики; сутки же считаются в поясе счётчика, поэтому" + " `toDate(UTCEventTime)` ≠ `EventDate`", ), Column( name="ClientTimeZone", diff --git a/generator/src/clickstream_generator/weights.py b/generator/src/clickstream_generator/weights.py new file mode 100644 index 0000000..4684948 --- /dev/null +++ b/generator/src/clickstream_generator/weights.py @@ -0,0 +1,37 @@ +"""Выбор по целым весам — общий приём плана состава и дня-функции. + +Дисциплина спеки (раздел 2): случайность тянется целыми числами, плавающие +распределения системной математики не зовутся — они расходятся между +версиями numpy и архитектурами CPU, а обещано побайтовое совпадение. Отсюда +и способ: веса складываются в накопленный ряд, бросок попадает в чью-то долю +общего веса, и кто долю занимал — тот и выбран. + +Модуль маленький нарочно: у приёма одно определение на весь генератор, +иначе дисциплина живёт копиями и расходится с ними. +""" + +import numpy as np +from numpy.typing import NDArray + + +def pick( + rng: np.random.Generator, cumulative: NDArray[np.int64], size: int +) -> NDArray[np.int64]: + """Куда попал бросок в общий вес — тот вариант и вышел.""" + return np.searchsorted(cumulative, rng.integers(0, cumulative[-1], size), "right") + + +def pick_row( + rng: np.random.Generator, cumulative: NDArray[np.int64], rows: NDArray[np.int64] +) -> NDArray[np.int64]: + """То же, но у каждого броска своя строка таблицы весов. + + Строки уложены встык — каждая начинается там, где кончилась предыдущая, — + и поиск идёт по одному ряду: столько же работы, сколько на одну таблицу. + Общий вес у строк поэтому обязан совпадать; у повёрнутой суточной волны + он совпадает по построению. + """ + total = cumulative[0, -1] + shelf = (cumulative + np.arange(cumulative.shape[0])[:, None] * total).ravel() + draw = rows * total + rng.integers(0, total, rows.size) + return np.searchsorted(shelf, draw, "right") - rows * cumulative.shape[1] diff --git a/generator/src/clickstream_generator/world.py b/generator/src/clickstream_generator/world.py index 584f04a..9740b59 100644 --- a/generator/src/clickstream_generator/world.py +++ b/generator/src/clickstream_generator/world.py @@ -13,6 +13,16 @@ from datetime import date +# Счётчик стенда: сайт один, номер — константа мира. +COUNTER_ID = 42150607 + +# Часовой пояс счётчика, минуты от UTC: Самара, UTC+4. Модельные сутки +# считаются в этом поясе, как в выгрузке Метрики: `EventDate` — дата в поясе +# счётчика, `UTCEventTime` — абсолютная метка. Отсюда следствие, о котором +# сторона хранилища должна знать заранее: `toDate(UTCEventTime)` ≠ `EventDate` +# у ночных событий (спека генератора, раздел 9). +COUNTER_TIMEZONE_MINUTES = 240 + # D0 — первый день оси модельного времени, понедельник. Реальный календарь в # модели не участвует: дата нужна лишь затем, чтобы дни оси легли в # `EventDate`/`UTCEventTime` конкретными числами. От даты запуска мир не @@ -25,9 +35,11 @@ ORIGIN = date(2026, 6, 1) DAILY_INFLUX = 3_800 # Недельная волна мира, проценты от среднего: понедельник … воскресенье. -# Профиль один на весь мир: по нему идёт приток, по нему же пойдёт трафик -# дня-функции — иначе доля новичков скакала бы по дням недели. В сумме ровно -# 700: за неделю средний день остаётся средним. +# Волна задана притоку — по ней в мир приходят новые люди. Трафик наследует +# её через дневную аудиторию, а не вторым умножением (довод — у суточной +# волны выходного дня), поэтому доля новичков по дням недели ровная, а +# недельный размах трафика выходит мягче притока: ±7% против ±10%. В сумме +# ровно 700: за неделю средний день остаётся средним. WEEKLY_PROFILE_PERCENT = (105, 108, 107, 105, 95, 88, 92) # Разброс притока изо дня в день, проценты: ровный приток выдал бы себя в @@ -43,13 +55,13 @@ ONE_SHOT_PERCENT = 75 # притоке 3 800 (спека, разделы 5 и 9). RETURN_COUNT_WEIGHTS = (25, 20, 15, 12, 9, 7, 5, 4, 2, 1) -# Хвост возвратов: окно активности человека от его первого визита, общее на +# Хвост возвратов: окно активности человека от его первого дня, общее на # обе его куки. За краем окна кука не возвращается. Оно же — глубина # предыстории: столько когорт живёт до D0, чтобы дневная аудитория была на # полке с самого первого дня. RETURN_TAIL_DAYS = 90 -# Профиль возвратов по дням от первого визита: почти всё в первую неделю, +# Профиль возвратов по дням от первого дня куки: почти всё в первую неделю, # дальше тонкий хвост до края окна — повторные покупки в магазине случаются # и через месяцы. Профиль затухает к краю, поэтому обрыв на нём в данных не # виден. Читается по парам «сколько дней — с каким весом»; дней в сумме @@ -69,3 +81,56 @@ BUYER_PERCENT = 5 # Такой паре план назначает по гарантированному заказу с каждой куки — на # этом стоит лаба про склейку личности. PAIRED_BUYER_PERCENT = 15 + +# --- Числа дня: суточная волна, визиты, воронка --------------------------- + +# Суточная волна буднего дня: проценты от среднего часа, от 00 до 23 часов +# местного времени посетителя. Ночной провал, обеденный и вечерний пики до +# ~2× среднего (спека генератора, раздел 2). В сумме ровно 2400: средний час +# остаётся средним, и суточный объём от формы волны не зависит. +WEEKDAY_HOURS_PERCENT = ( + 35, 20, 12, 8, 8, 12, 25, 45, 70, 105, 115, 130, + 170, 165, 140, 130, 130, 140, 160, 185, 210, 180, 130, 75, +) # fmt: skip + +# Выходной день: подъём позже, обеденного пика нет — день ровнее, вечер +# ниже буднего. Здесь только форма, поэтому сумма та же — 2400: объём +# выходного день-функция не трогает, он приходит сам, потому что дневная +# аудитория уже дышит недельной волной через приток. Умножить на неё +# второй раз значило бы удвоить недельный размах. +WEEKEND_HOURS_PERCENT = ( + 45, 30, 20, 12, 10, 10, 14, 22, 40, 70, 105, 140, + 165, 160, 175, 175, 170, 165, 165, 175, 180, 165, 120, 67, +) # fmt: skip + +# Сколько визитов у куки в её день активности: веса для 1, 2 и 3 визитов. +# В среднем ≈1,4 — при дневной аудитории 6–7 тыс. это 8–10 тыс. визитов +# (спека, раздел 5). +VISITS_PER_ACTIVE_DAY_WEIGHTS = (70, 22, 8) + +# Длина визита в страницах: веса для 1, 2, 3 … страниц. Первая доля — отказы +# (посмотрел одну страницу и ушёл), дальше затухающий хвост. В среднем ≈4,8 +# страницы: вместе с числом визитов это ~45 тыс. pageview в средний день, +# и до ~50 тыс. добирают торговые события (#40). +VISIT_PAGES_WEIGHTS = (250, 150, 120, 100, 88, 78, 68, 58, 50, 42, 35, 28, 22, 16) + +# Таймаут визита: пауза дольше этой рвёт визит надвое. Правило резки, по +# которому лаба сессий сверяет свою сборку с `VisitID` (мастер-спека, +# раздел 1.2), поэтому паузы внутри визита всегда короче, а соседние визиты +# одной куки всегда разведены дальше. +VISIT_TIMEOUT_SECONDS = 1800 + +# Пауза между соседними страницами визита, секунды: обычная и «задумался». +# Обе целиком внутри таймаута — иначе визит распался бы там, где генератор +# этого не обещал. +PAGE_PAUSE_SECONDS = (8, 300) +LONG_PAUSE_SECONDS = (300, 1500) +LONG_PAUSE_PERCENT = 12 + +# Воронка: доля визитов, дошедших до корзины, и доли следующих шагов от +# предыдущего. Произведение — конверсия визита в оформленный заказ: 2% +# (спека генератора, раздел 9). Гарантированные планом заказы двухкуковых +# пар проходят воронку целиком независимо от этих долей. +CART_PERCENT = 8 +CHECKOUT_OF_CART_PERCENT = 45 +CONFIRMATION_OF_CHECKOUT_PERCENT = 55 diff --git a/generator/tests/test_catalog.py b/generator/tests/test_catalog.py new file mode 100644 index 0000000..2de568b --- /dev/null +++ b/generator/tests/test_catalog.py @@ -0,0 +1,79 @@ +"""Каталог товаров: форма файла, а не его длина. + +Файл дорастает механически, поэтому ни один тест не считает его строки и +не знает ни одного артикула наизусть. Сторожится ровно то, на что опираются +генератор и словарь ClickHouse: колонки, вид артикула, известные категории, +целая цена. +""" + +import csv +import re + +import numpy as np + +from clickstream_generator import catalog + +SKU = re.compile(r"^[A-Z]{4}-\d{4}$") + +# Вилка цены, копейки: от сотни рублей до полумиллиона. Сторож от нуля, +# от минуса и от цены, случайно записанной в рублях. +PRICE_RANGE = (10_000, 50_000_000) + + +def rows() -> list[dict[str, str]]: + with catalog.CATALOG_PATH.open(encoding="utf-8", newline="") as source: + return list(csv.DictReader(source)) + + +def test_the_file_has_the_columns_the_dictionary_expects(): + with catalog.CATALOG_PATH.open(encoding="utf-8", newline="") as source: + assert next(csv.reader(source)) == list(catalog.COLUMNS) + + +def test_the_catalog_is_not_empty(): + assert rows() + + +def test_every_article_is_unique_and_named_by_its_category(): + known = {category.name: category.prefix for category in catalog.CATEGORIES} + articles = set() + for row in rows(): + sku = row["sku"] + assert SKU.match(sku), sku + assert sku.split("-")[0] == known[row["category"]], sku + articles.add(sku) + assert len(articles) == len(rows()) + + +def test_every_row_is_filled_and_priced_in_whole_kopecks(): + low, high = PRICE_RANGE + for row in rows(): + assert row["name"].strip() + assert row["brand"].strip() + assert row["price"].isdigit(), row["price"] + assert low <= int(row["price"]) <= high, row["sku"] + + +def test_every_category_of_the_assortment_is_covered(): + """Каталог покрывает ассортимент целиком: пустых категорий не бывает.""" + present = {row["category"] for row in rows()} + assert present == {category.name for category in catalog.CATEGORIES} + + +def test_categories_are_told_apart_by_prefix_and_by_address(): + prefixes = {category.prefix for category in catalog.CATEGORIES} + slugs = {category.slug for category in catalog.CATEGORIES} + assert len(prefixes) == len(slugs) == len(catalog.CATEGORIES) + + +def test_the_catalog_is_grouped_by_category_whatever_the_file_order(): + goods = catalog.catalog() + assert goods.count.sum() == goods.sku.size + for number in range(len(catalog.CATEGORIES)): + first = goods.first[number] + rows_here = goods.grouped[first : first + goods.count[number]] + assert np.all(goods.category[rows_here] == number) + + +def test_the_catalog_is_read_once(): + assert catalog.catalog() is catalog.catalog() diff --git a/generator/tests/test_day.py b/generator/tests/test_day.py new file mode 100644 index 0000000..6f0f8c7 --- /dev/null +++ b/generator/tests/test_day.py @@ -0,0 +1,357 @@ +"""День-функция: чистота, форма волны, правила резки визитов, шов для #40. + +Числа мира тесты сторожат вилками спеки, а не точными значениями: менти +крутит конфигурацию, и падать тесты должны там, где сдвинулся вывод («средний +день ~50 тыс. событий», «ночью провал, вечером пик»), а не при каждой правке. +""" + +import inspect +import re +from dataclasses import replace +from typing import Any + +import numpy as np +import pytest +from numpy.typing import NDArray + +from clickstream_generator import catalog, day, plan, reference, schema, world +from clickstream_generator.reference import Page +from clickstream_generator.seeds import CANONICAL_SEED + +WEEKDAY = 2 +WEEKEND = 5 + + +@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) + + +def readable(column: NDArray[Any]) -> list[Any]: + """Колонка в сравнимом виде: массив внутри ячейки — в список.""" + if column.dtype == object: + return [ + cell.tolist() if isinstance(cell, np.ndarray) else cell for cell in column + ] + return column.tolist() + + +def snapshot(events: day.Day) -> dict[str, Any]: + """Слепок дня для сравнений: всё, что день отдал наружу. + + Порядок колонок в слепке живёт отдельным списком: словари сравниваются + без оглядки на него, а порядок — часть обещания (он же порядок + контракта схемы). Швы `page` и `product` — тоже часть отдаваемого, и + сторожить их надо тем же слепком, а не отдельной памятью. + """ + return { + "order": list(events.columns), + "values": [readable(column) for column in events.columns.values()], + "page": events.page.tolist(), + "product": events.product.tolist(), + } + + +def local_seconds(events: day.Day) -> NDArray[np.int64]: + """Секунда события внутри модельных суток — в поясе счётчика.""" + midnight = np.datetime64(world.ORIGIN, "s") + np.timedelta64(events.day, "D") + away = np.timedelta64(world.COUNTER_TIMEZONE_MINUTES, "m") + return (events.columns["UTCEventTime"] - midnight + away).astype("int64") + + +def hourly(events: day.Day) -> NDArray[np.int64]: + return np.bincount(local_seconds(events) // 3600, minlength=24) + + +def test_the_snapshot_notices_everything_the_day_hands_out(weekday: day.Day): + """Сторож сторожей: слепок обязан замечать порядок колонок и швы. + + Слепок слепой к чему-нибудь — это три зелёных теста детерминизма при + поехавшем выводе, а не одна пропущенная мелочь. + """ + original = snapshot(weekday) + reordered = day.Day( + day=weekday.day, + columns=dict(reversed(list(weekday.columns.items()))), + page=weekday.page, + product=weekday.product, + ) + assert snapshot(reordered) != original + + moved = weekday.product.copy() + moved[0] += 1 + assert snapshot(replace(weekday, product=moved)) != original + shifted = weekday.page.copy() + shifted[0] += 1 + assert snapshot(replace(weekday, page=shifted)) != original + + +def test_a_day_is_a_pure_function_of_the_seed_and_the_day(): + first = snapshot(day.stream(CANONICAL_SEED, 3)) + plan.cohort.cache_clear() + assert snapshot(day.stream(CANONICAL_SEED, 3)) == first + + +def test_a_day_generated_alone_is_the_same_day(): + """День D не зависит от того, прожиты ли дни до него.""" + alone = snapshot(day.stream(CANONICAL_SEED, 3)) + plan.cohort.cache_clear() + for earlier in range(3): + day.stream(CANONICAL_SEED, earlier) + assert snapshot(day.stream(CANONICAL_SEED, 3)) == alone + + +def test_the_next_day_does_not_move_the_days_before_it(): + before = [snapshot(day.stream(CANONICAL_SEED, number)) for number in range(2)] + day.stream(CANONICAL_SEED, 2) + assert [snapshot(day.stream(CANONICAL_SEED, n)) for n in range(2)] == before + + +def test_another_seed_is_another_day(): + ours = snapshot(day.stream(CANONICAL_SEED, 1)) + assert snapshot(day.stream(CANONICAL_SEED + 1, 1)) != ours + + +def test_events_do_not_start_before_the_origin(): + with pytest.raises(ValueError): + day.stream(CANONICAL_SEED, -1) + + +def test_every_column_of_the_contract_is_present_and_typed(weekday: day.Day): + """Состав колонок решает контракт схемы; день обязан отдать их все.""" + assert list(weekday.columns) == [column.name for column in schema.COLUMNS] + for column in schema.COLUMNS: + values = weekday.columns[column.name] + assert values.size == len(weekday), column.name + if column.clickhouse_type.startswith("Array("): + cells = {cell.dtype for cell in values[:1000]} + assert cells == {np.dtype(column.numpy_dtype)}, column.name + else: + 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): + """Пусто — пустой массив и пустая строка, а ключ есть у каждого события. + + Проверяются все колонки будущих торговых событий, а не выбранные: тикет + #40 добавит свои, и они должны попасть под тот же сторож. + """ + waiting = [ + 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] + 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_no_column_hides_a_hole(weekday: day.Day): + """`None` в колонке — та же пропажа ключа, только позже и незаметнее.""" + for column in schema.COLUMNS: + values = weekday.columns[column.name] + if values.dtype != object: + continue + kind = np.ndarray if column.clickhouse_type.startswith("Array(") else str + assert all(isinstance(cell, kind) for cell in values[:1000]), column.name + + +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].dtype == np.uint64 + + +def test_the_event_id_is_unique_because_it_deduplicates(weekday: day.Day): + """`WatchID` — ключ склейки при переигровке дня (спека, раздел 4).""" + watch = weekday.columns["WatchID"] + assert len(set(watch.tolist())) == watch.size + + +def test_a_visit_belongs_to_one_cookie(weekday: day.Day): + visit = weekday.columns["VisitID"] + cookie = weekday.columns["ClientID"] + pairs = set(zip(visit.tolist(), cookie.tolist(), strict=True)) + assert len(pairs) == len(set(visit.tolist())) + + +def test_the_visit_cutting_rules_rebuild_the_generator_visits(weekday: day.Day): + """Правило лабы: та же кука, пауза не длиннее таймаута — тот же визит. + + Сборка сессий по правилам из докстринга модуля обязана совпасть с + `VisitID` — на этой сверке стоит лаба сессий. + """ + second = local_seconds(weekday) + cookie = weekday.columns["ClientID"] + order = np.lexsort((second, cookie)) + cookie, second = cookie[order], second[order] + visit = weekday.columns["VisitID"][order] + + started = np.ones(visit.size, dtype=bool) + started[1:] = (cookie[1:] != cookie[:-1]) | ( + second[1:] - second[:-1] > world.VISIT_TIMEOUT_SECONDS + ) + rebuilt = np.cumsum(started) + # Совпадение обоюдное: сколько пар «собранный визит — `VisitID`», столько + # же и тех, и других. Одного равенства мало — оно ловит только склейку + # двух визитов в один, а разрыв одного визита надвое проходит мимо. + matched = set(zip(rebuilt.tolist(), visit.tolist(), strict=True)) + assert len(matched) == int(rebuilt.max()) + assert len(matched) == len(set(visit.tolist())) + + +def test_the_day_boundary_cuts_the_visits(weekday: day.Day): + """Событий за границей модельных суток в дне нет — партиция дня целая.""" + second = local_seconds(weekday) + assert second.min() >= 0 + assert second.max() < day.DAY_SECONDS + date = np.datetime64(world.ORIGIN, "D") + np.timedelta64(weekday.day, "D") + assert set(weekday.columns["EventDate"].tolist()) == {date.astype("O")} + + +def test_the_counter_timezone_moves_the_date_apart_from_utc(weekday: day.Day): + """`toDate(UTCEventTime)` ≠ `EventDate`: сутки считаются в поясе счётчика.""" + utc_date = weekday.columns["UTCEventTime"].astype("datetime64[D]") + assert np.any(utc_date != weekday.columns["EventDate"]) + + +def test_the_scale_of_an_average_day_is_about_fifty_thousand(weekday: day.Day): + """Порядок величины (спека, раздел 5); итог сложится после #40.""" + assert 30_000 < len(weekday) < 70_000 + visits = len(set(weekday.columns["VisitID"].tolist())) + assert 6_000 < visits < 14_000 + + +def test_the_wave_dips_at_night_and_peaks_in_the_evening(weekday: day.Day): + counters = hourly(weekday) + average = counters.mean() + assert counters[2:6].max() < average / 2 + assert counters[18:22].max() > 1.5 * average + assert 0 <= int(counters.argmin()) <= 6 + assert 11 <= int(counters.argmax()) <= 22 + + +def test_the_weekend_is_shaped_unlike_a_weekday(weekday: day.Day): + """Форма, а не объём: выходной раскачивается позже буднего.""" + weekend = hourly(day.stream(CANONICAL_SEED, WEEKEND)) + workday = hourly(weekday) + morning = slice(7, 10) + assert weekend[morning].sum() / weekend.sum() < ( + workday[morning].sum() / workday.sum() + ) + + +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()) + assert 0.01 < ordered / visits < 0.04 + + +def test_the_promised_orders_reach_the_confirmation(weekday: day.Day): + """Гарантия двухкуковых пар: назначенный планом заказ обязан состояться.""" + audience = plan.audience(CANONICAL_SEED, weekday.day) + promised = set(audience.client_id[audience.assigned_order].tolist()) + assert promised + confirmed = weekday.columns["ClientID"][weekday.page == Page.CONFIRMATION] + assert promised <= set(confirmed.tolist()) + + +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) + assert carts.size > 0 + assert np.all(by_visit[carts - 1] == Page.PRODUCT) + + +def test_the_product_of_a_card_is_the_seam_for_trade_events(weekday: day.Day): + """`product` — товар карточки и −1 у прочих страниц; больше ничего.""" + goods = catalog.catalog() + card = weekday.page == Page.PRODUCT + assert np.all(weekday.product[~card] == -1) + assert np.all(weekday.product[card] >= 0) + assert np.all(weekday.product[card] < goods.sku.size) + shown = zip(weekday.columns["URL"][card], weekday.product[card], strict=True) + for url, number in list(shown)[:200]: + # У страницы входа в адресе ещё метки перехода — путь до знака «?». + assert url.split("?")[0].endswith(f"/product/{goods.sku[number]}") + + +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] + inside = np.flatnonzero(visit[1:] == visit[:-1]) + 1 + assert np.all(referer[inside] == url[inside - 1]) + + entry = np.flatnonzero(visit[1:] != visit[:-1]) + 1 + known = {source.referer for source in reference.TRAFFIC_SOURCES} + assert set(referer[entry].tolist()) <= known + + +def test_the_passport_of_a_cookie_does_not_change_between_days(): + """Кука — браузер на устройстве: во всех её днях профиль и город одни.""" + passports: dict[int, tuple[Any, ...]] = {} + for number in (0, 1, 2): + events = day.stream(CANONICAL_SEED, number) + seen = zip( + events.columns["ClientID"].tolist(), + events.columns["Browser"].tolist(), + events.columns["ScreenWidth"].tolist(), + events.columns["RegionCity"].tolist(), + strict=True, + ) + for cookie, *passport in seen: + assert passports.setdefault(cookie, tuple(passport)) == tuple(passport) + + +def test_geography_is_one_region_of_presence(weekday: day.Day): + """Магазин с одним складом: свой миллионник, свой регион, тонкий хвост.""" + cities = weekday.columns["RegionCity"] + assert (cities == "Samara").mean() > 0.25 + assert set(weekday.columns["RegionCountry"].tolist()) == {reference.COUNTRY_NAME} + assert set(weekday.columns["RegionCountryID"].tolist()) == { + reference.COUNTRY_REGION_ID + } + + +def test_addresses_are_not_routable(weekday: day.Day): + """Правдоподобные публичные адреса принадлежат живым организациям.""" + fixed = ("192.0.2.", "198.51.100.", "203.0.113.", "198.18.") + + def cgnat(address: str) -> bool: + first, second, *_ = (int(byte) for byte in address.split(".")) + return first == reference.MOBILE_IP_FIRST_BYTE and 64 <= second < 128 + + for address in set(weekday.columns["IPAddress"].tolist()): + assert address.startswith(fixed) or cgnat(address), address + + +def test_phones_carry_a_model_and_desktops_do_not(weekday: day.Day): + phone = weekday.columns["DeviceCategory"] == reference.PHONE_CATEGORY + assert phone.mean() > 0.5 + assert np.all(weekday.columns["MobilePhoneModel"][~phone] == "") + assert np.all(weekday.columns["MobilePhoneModel"][phone] != "") + + +def test_randomness_is_drawn_in_whole_numbers(): + """Дисциплина спеки (раздел 2): целые числа, никакой системной математики.""" + source = inspect.getsource(day) + assert set(re.findall(r"\brng\.(\w+)", source)) <= {"integers"} + assert not re.search(r"^\s*import (random|math)\b", source, re.MULTILINE) diff --git a/generator/tests/test_plan.py b/generator/tests/test_plan.py index f25ba79..6ef3b29 100644 --- a/generator/tests/test_plan.py +++ b/generator/tests/test_plan.py @@ -11,7 +11,7 @@ import re import numpy as np import pytest -from clickstream_generator import plan, world +from clickstream_generator import plan, reference, world from clickstream_generator.seeds import CANONICAL_SEED # Горизонт эталонного снимка — две недели (спека, раздел 5). @@ -24,9 +24,9 @@ def fresh_memo(): plan.cohort.cache_clear() -def visits_of(cohort: plan.Cohort) -> list[tuple[int, int]]: - """Таблица визитов парами «кука — день», как её видит день-функция.""" - cookies, days = cohort.visit_cookie.tolist(), cohort.visit_day.tolist() +def active_days_of(cohort: plan.Cohort) -> list[tuple[int, int]]: + """Таблица парами «кука — день активности», как её видит день-функция.""" + cookies, days = cohort.active_cookie.tolist(), cohort.active_day.tolist() return list(zip(cookies, days, strict=True)) @@ -37,10 +37,12 @@ def same_cohort(left: plan.Cohort, right: plan.Cohort) -> bool: and np.array_equal(left.client_id, right.client_id) and np.array_equal(left.buyer, right.buyer) and np.array_equal(left.birth_day, right.birth_day) - and np.array_equal(left.visit_cookie, right.visit_cookie) - and np.array_equal(left.visit_day, right.visit_day) + and np.array_equal(left.active_cookie, right.active_cookie) + and np.array_equal(left.active_day, right.active_day) and np.array_equal(left.pair_cookies, right.pair_cookies) and np.array_equal(left.pair_order_days, right.pair_order_days) + and np.array_equal(left.device, right.device) + and np.array_equal(left.city, right.city) ) @@ -92,25 +94,25 @@ def test_events_do_not_start_before_the_origin(): @pytest.mark.parametrize("day", [-world.RETURN_TAIL_DAYS, -1, 0, 6, 13]) -def test_visits_stay_inside_the_activity_window(day: int): - """Окно активности — хвост возвратов от первого визита человека.""" +def test_active_days_stay_inside_the_activity_window(day: int): + """Окно активности — хвост возвратов от первого дня человека.""" cohort = plan.cohort(CANONICAL_SEED, day) - assert cohort.visit_day.min() == day - assert cohort.visit_day.max() <= day + world.RETURN_TAIL_DAYS + assert cohort.active_day.min() == day + assert cohort.active_day.max() <= day + world.RETURN_TAIL_DAYS -def test_every_cookie_visits_on_the_day_it_was_born(): +def test_every_cookie_is_active_on_the_day_it_was_born(): cohort = plan.cohort(CANONICAL_SEED, 0) cookies = range(cohort.client_id.size) born = zip(cookies, cohort.birth_day.tolist(), strict=True) - assert set(born) <= set(visits_of(cohort)) + assert set(born) <= set(active_days_of(cohort)) -def test_a_cookie_visits_a_day_once(): +def test_a_cookie_gets_one_active_day_at_a_time(): cohort = plan.cohort(CANONICAL_SEED, 0) - visits = visits_of(cohort) - assert visits == sorted(visits) - assert len(set(visits)) == len(visits) + days = active_days_of(cohort) + assert days == sorted(days) + assert len(set(days)) == len(days) def test_client_ids_are_unique_and_survive_json(): @@ -144,16 +146,44 @@ def test_pairs_are_the_agreed_share_of_buyers(): @pytest.mark.parametrize("day", [-world.RETURN_TAIL_DAYS, -20, 0, 5]) def test_every_pair_orders_from_both_cookies_on_the_axis(day: int): - """Гарантия двухкуковых: заказ назначен на день визита куки, не раньше D0.""" + """Гарантия двухкуковых: заказ назначен на день активности куки, от D0.""" cohort = plan.cohort(CANONICAL_SEED, day) - visits = set(visits_of(cohort)) + days_of = set(active_days_of(cohort)) for cookies, days in zip( cohort.pair_cookies.tolist(), cohort.pair_order_days.tolist(), strict=True ): assert cookies[0] != cookies[1], "заказы пары — с двух разных кук" for cookie, order_day in zip(cookies, days, strict=True): assert order_day >= 0 - assert (cookie, order_day) in visits + assert (cookie, order_day) in days_of + + +def test_the_passport_of_a_pair_is_one_person_with_two_devices(): + """Два города у одного человека — ложь в данных (спека, раздел 9).""" + cohort = plan.cohort(CANONICAL_SEED, 0) + first, second = cohort.pair_cookies[:, 0], cohort.pair_cookies[:, 1] + assert np.all(cohort.city[first] == cohort.city[second]) + assert np.all(cohort.device[first] != cohort.device[second]) + + # Обещано не просто «разные строки справочника», а разный род устройства: + # ровно одно из двух — телефон, второе десктоп или планшет. Пара «телефон + # и ноутбук» мастер-спеки (раздел 5) — про это, а не про запрет планшета. + category = np.array([row.category for row in reference.DEVICE_PROFILES]) + kinds = category[cohort.device[first]], category[cohort.device[second]] + assert np.all(kinds[0] != kinds[1]) + phone = category == reference.PHONE_CATEGORY + assert np.all(phone[cohort.device[first]] != phone[cohort.device[second]]) + + +def test_the_passport_points_into_the_directories(): + cohort = plan.cohort(CANONICAL_SEED, 0) + for passport, table in ( + (cohort.device, reference.DEVICE_PROFILES), + (cohort.city, reference.CITIES), + ): + assert passport.size == cohort.client_id.size + assert passport.min() >= 0 + assert passport.max() < len(table) def test_the_daily_audience_matches_the_spec_band(): @@ -244,8 +274,8 @@ def test_plan_arrays_are_whole_numbers(): for array in ( cohort.client_id, cohort.birth_day, - cohort.visit_cookie, - cohort.visit_day, + cohort.active_cookie, + cohort.active_day, cohort.pair_cookies, cohort.pair_order_days, ): diff --git a/generator/tests/test_reference.py b/generator/tests/test_reference.py new file mode 100644 index 0000000..6906b8e --- /dev/null +++ b/generator/tests/test_reference.py @@ -0,0 +1,92 @@ +"""Сторожа справочников: связки, из-за которых таблицы написаны руками. + +Тесты держат не содержание строк — его менти волен менять, — а то, на чём +стоит день-функция: доли складываются в сотню, вход и переходы разложены по +страницам блуждания, устройство не противоречит само себе. +""" + +from clickstream_generator import reference + + +def test_shares_of_every_directory_add_up_to_a_hundred(): + """Веса — проценты: выбор по ним целочисленный и без остатка.""" + for table in ( + reference.DEVICE_PROFILES, + reference.CITIES, + reference.TRAFFIC_SOURCES, + ): + assert sum(row.weight for row in table) == 100 + + +def test_walking_the_site_never_leaves_the_browsing_pages(): + for percent in (reference.FROM_PRODUCT_PERCENT, reference.FROM_LISTING_PERCENT): + assert len(percent) == len(reference.BROWSE_PAGES) + assert sum(percent) == 100 + + +def test_a_visitor_comes_to_a_page_that_exists(): + for source in reference.TRAFFIC_SOURCES: + assert len(source.entry_percent) == len(reference.BROWSE_PAGES) + assert sum(source.entry_percent) == 100 + + +def test_the_walk_prefers_the_product_card(): + """Иначе магазин выглядел бы каталогом, который никто не открывает.""" + card = reference.BROWSE_PAGES.index(reference.Page.PRODUCT) + assert reference.FROM_LISTING_PERCENT[card] > 50 + + +def test_a_phone_carries_a_model_and_a_desktop_does_not(): + for profile in reference.DEVICE_PROFILES: + phone = profile.category == reference.PHONE_CATEGORY + assert bool(profile.phone_model) == phone + assert profile.screen_width > 0 and profile.screen_height > 0 + + +def test_mobile_traffic_outweighs_the_desktop(): + """Российская розница мобильная; на этом стоят доли устройств.""" + phones = sum( + row.weight + for row in reference.DEVICE_PROFILES + if row.category == reference.PHONE_CATEGORY + ) + assert phones > 50 + + +def test_cities_are_told_apart_by_id_and_by_address_block(): + assert len({city.region_id for city in reference.CITIES}) == len(reference.CITIES) + assert len({city.ip_prefix for city in reference.CITIES}) == len(reference.CITIES) + assert reference.COUNTRY_REGION_ID not in {c.region_id for c in reference.CITIES} + + +def test_the_region_of_presence_outweighs_the_rest_of_the_country(): + """Один регион присутствия, а не «топ городов России» (спека, раздел 9).""" + home = reference.CITIES[0] + assert home.name == "Samara" + assert home.weight > 30 + nearby = sum( + city.weight + for city in reference.CITIES + if city.timezone_minutes == home.timezone_minutes + ) + assert nearby > 50 + + +def test_timezones_are_whole_hours(): + """Волна поворачивается на целые часы: получасовых поясов в мире нет.""" + for city in reference.CITIES: + assert city.timezone_minutes % 60 == 0 + + +def test_paid_sources_carry_their_click_labels(): + for source in reference.TRAFFIC_SOURCES: + paid = source.last_traffic_source == "ad" + assert bool(source.utm_source) >= paid + assert (source.has_gclid or source.has_yclid) == paid + + +def test_free_sources_carry_no_utm(): + """Метки ставит тот, кто платит: у organic и direct их не бывает.""" + for source in reference.TRAFFIC_SOURCES: + if source.last_traffic_source in ("organic", "direct", "recommend"): + assert not source.utm_source diff --git a/generator/tests/test_world.py b/generator/tests/test_world.py index b953763..3521a20 100644 --- a/generator/tests/test_world.py +++ b/generator/tests/test_world.py @@ -64,3 +64,56 @@ def test_most_returns_land_in_the_first_days(): def test_pairs_are_a_small_part_of_the_cohort(): """Вторые куки пар добавляют к притоку меньше процента (спека, раздел 9).""" assert world.BUYER_PERCENT * world.PAIRED_BUYER_PERCENT < 100 + + +def test_both_daily_waves_cover_a_day_and_average_to_one(): + """Форма волны не меняет суточный объём: его задаёт недельный профиль.""" + for profile in (world.WEEKDAY_HOURS_PERCENT, world.WEEKEND_HOURS_PERCENT): + assert len(profile) == 24 + assert sum(profile) == 2400 + + +def test_the_wave_dips_at_night_and_peaks_up_to_twice_the_average(): + """Пики до ~2× среднего, ночью провал (спека, раздел 2).""" + for profile in (world.WEEKDAY_HOURS_PERCENT, world.WEEKEND_HOURS_PERCENT): + assert min(profile[2:6]) < 50 + assert 150 < max(profile) <= 220 + + +def test_the_weekend_wakes_up_later_than_a_weekday(): + morning = slice(7, 10) + assert sum(world.WEEKEND_HOURS_PERCENT[morning]) < sum( + world.WEEKDAY_HOURS_PERCENT[morning] + ) + + +def test_an_active_day_holds_a_visit_or_two(): + """Дневная аудитория 6–8 тыс. и 8–12 тыс. визитов сходятся через это число.""" + weights = world.VISITS_PER_ACTIVE_DAY_WEIGHTS + counts = tuple(range(1, len(weights) + 1)) + assert 1.2 <= mean_by_weights(counts, weights) <= 1.6 + + +def test_a_visit_is_a_few_pages_long_and_often_a_single_one(): + weights = world.VISIT_PAGES_WEIGHTS + pages = tuple(range(1, len(weights) + 1)) + assert 4 <= mean_by_weights(pages, weights) <= 6 + bounced = weights[0] / sum(weights) + assert 0.15 < bounced < 0.35 + + +def test_pauses_stay_inside_the_visit_timeout(): + """Иначе визит распался бы там, где генератор этого не обещал.""" + assert max(world.PAGE_PAUSE_SECONDS) < world.VISIT_TIMEOUT_SECONDS + 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