feat(generator): день-функция — трафик, визиты и просмотры страниц #49

Merged
ddmitry merged 2 commits from feat/39-day-function into main 2026-08-02 16:32:49 +03:00
18 changed files with 2100 additions and 105 deletions
+30 -4
View File
@@ -31,8 +31,8 @@ _Избегать_: состояние мира
там колонки, здесь числа. там колонки, здесь числа.
**Когорта дня**: **Когорта дня**:
Люди, впервые пришедшие в мир в один день, со всеми их куками и визитами. Люди, впервые пришедшие в мир в один день, со всеми их куками и днями
Единица плана состава: когорта — функция зерна и номера дня. активности. Единица плана состава: когорта — функция зерна и номера дня.
**Приток**: **Приток**:
Появление новых кук на всём протяжении оси модельного времени; единица — Появление новых кук на всём протяжении оси модельного времени; единица —
@@ -40,11 +40,37 @@ _Избегать_: состояние мира
горизонтом и не совпадает с дневной. горизонтом и не совпадает с дневной.
**Хвост возвратов**: **Хвост возвратов**:
Окно активности, отсчитанное от первого визита человека и общее на обе Окно активности, отсчитанное от первого дня человека и общее на обе
его куки; дольше окна кука не возвращается. его куки; дольше окна кука не возвращается.
**Предыстория**: **Предыстория**:
Когорты плана с первым визитом до D0; событий не порождают. Когорты плана с первым днём активности до D0; событий не порождают.
**День активности**:
День, в который кука хоть раз появилась в мире: день её рождения или день
возврата. Единица плана состава: за день он у куки один, а визитов внутри
него бывает несколько.
_Избегать_: визит (визит — про сессию)
**Визит**:
Подряд идущие события одной куки без пауз длиннее 30 минут, не пересекающие
границу суток. Поле `VisitID` — эталон для лабы сессий. «Сессия» — то же
понятие словами аналитики: в данных и в коде оно зовётся визитом, но имена
вроде «лаба сессий» и «сборка сессий» остаются.
**Паспорт куки**:
Устройство и город, приписанные куке на всю жизнь: кука — это браузер на
устройстве. Держит их план состава, разворачивает в поля события
день-функция; у двухкуковой пары город один на две куки, устройства разные.
**День-функция**:
Функция (зерно, номер дня), выдающая упорядоченный поток событий этих
модельных суток. Состояния между днями нет: день D не зависит от того,
прожиты ли дни до него.
**Каталог товаров**:
`data/catalog/products.csv` — общий справочник генератора и словаря
ClickHouse. Форма файла решена, длина — нет: строки дописываются.
**Ось модельного времени**: **Ось модельного времени**:
Собственный календарь мира генератора. Дни считаются от фиксированного Собственный календарь мира генератора. Дни считаются от фиксированного
+181
View File
@@ -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
1 sku name category brand price
2 HOME-0001 Комплект органайзеров для шкафа Товары для дома Домовит 129000
3 HOME-0002 Сушилка для белья напольная Товары для дома Домовит 249900
4 HOME-0003 Настольная лампа «Тихий вечер» Товары для дома Ладный дом 189000
5 HOME-0004 Набор вешалок 12 штук Товары для дома Домовит 79000
6 TEXT-0001 Комплект постельного белья полутораспальный Текстиль Сатин 349000
7 TEXT-0002 Плед-покрывало 200×220 Текстиль Сатин 279000
8 TEXT-0003 Полотенце банное 70×140 Текстиль Сатин 89000
9 TEXT-0004 Штора блэкаут 200×260 Текстиль Ладный дом 219000
10 POSU-0001 Сковорода 26 см с антипригарным покрытием Посуда Кухмастер 199000
11 POSU-0002 Набор кастрюль 3 предмета Посуда Кухмастер 459000
12 POSU-0003 Чайник заварочный стеклянный 1 л Посуда Кухмастер 119000
13 POSU-0004 Набор столовых приборов 24 предмета Посуда Ладья 289000
14 TECH-0001 Электрочайник 1.7 л Бытовая техника Электрон 189000
15 TECH-0002 Блендер погружной 800 Вт Бытовая техника Электрон 279000
16 TECH-0003 Отпариватель ручной Бытовая техника Электрон 349000
17 TECH-0004 Робот-пылесос Бытовая техника Электрон 1899000
18 KIDS-0001 Стульчик для кормления Детские товары Малышок 649000
19 KIDS-0002 Ночник-проектор «Звёзды» Детские товары Малышок 129000
20 KIDS-0003 Набор детской посуды 5 предметов Детские товары Малышок 89000
21 WEAR-0001 Тапочки домашние войлочные Одежда и обувь Ходики 99000
22 WEAR-0002 Халат махровый Одежда и обувь Сатин 249000
23 WEAR-0003 Носки хлопковые 5 пар Одежда и обувь Ходики 59000
24 HOME-0005 Корзина для белья 45 л Товары для дома Домовит 119000
25 HOME-0006 Контейнер для хранения 18 л Товары для дома Домовит 69000
26 HOME-0007 Чехол для одежды 60×100 Товары для дома Ладный дом 39000
27 HOME-0008 Коврик придверный резиновый 40×60 Товары для дома Домовит 59000
28 HOME-0009 Гладильная доска настольная 73×30 Товары для дома Ладный дом 149000
29 HOME-0010 Ведро хозяйственное с крышкой 12 л Товары для дома Домовит 49000
30 HOME-0011 Швабра с отжимом и ведром 8 л Товары для дома Домовит 179000
31 HOME-0012 Щётка для пола с черенком 120 см Товары для дома Домовит 45000
32 HOME-0013 Совок со щёткой на длинной ручке Товары для дома Домовит 79000
33 HOME-0014 Таз хозяйственный круглый 14 л Товары для дома Домовит 39000
34 HOME-0015 Этажерка для ванной 3 яруса Товары для дома Ладный дом 139000
35 HOME-0016 Полка настенная металлическая 45 см Товары для дома Ладный дом 99000
36 HOME-0017 Зеркало настенное круглое 50 см Товары для дома Ладный дом 169000
37 HOME-0018 Набор коробок для обуви 6 штук Товары для дома Домовит 129000
38 HOME-0019 Подставка для зонтов металлическая Товары для дома Ладный дом 159000
39 HOME-0020 Крючки самоклеящиеся 8 штук Товары для дома Домовит 29000
40 HOME-0021 Верёвка бельевая 20 м Товары для дома Домовит 19000
41 HOME-0022 Прищепки усиленные 24 штуки Товары для дома Домовит 25000
42 HOME-0023 Мешки вакуумные для одежды 5 штук Товары для дома Домовит 69000
43 HOME-0024 Органайзер подвесной с 12 карманами Товары для дома Ладный дом 79000
44 HOME-0025 Часы настенные круглые 30 см Товары для дома Ладный дом 89000
45 HOME-0026 Светильник настольный с прищепкой 7 Вт Товары для дома Ладный дом 119000
46 HOME-0027 Удлинитель бытовой 3 розетки 3 м Товары для дома Ладный дом 99000
47 HOME-0028 Лестница-стремянка 3 ступени Товары для дома Домовит 299000
48 HOME-0029 Ящик для инструментов 16 дюймов Товары для дома Домовит 139000
49 HOME-0030 Набор плечиков для брюк 6 штук Товары для дома Домовит 89000
50 TEXT-0005 Пододеяльник двуспальный 175×215 Текстиль Сатин 169000
51 TEXT-0006 Простыня на резинке 160×200 Текстиль Сатин 149000
52 TEXT-0007 Наволочки хлопковые 50×70 2 штуки Текстиль Сатин 79000
53 TEXT-0008 Одеяло всесезонное 172×205 Текстиль Сатин 299000
54 TEXT-0009 Подушка стёганая 50×70 Текстиль Сатин 129000
55 TEXT-0010 Наматрасник непромокаемый 160×200 Текстиль Сатин 189000
56 TEXT-0011 Покрывало стёганое 180×210 Текстиль Сатин 239000
57 TEXT-0012 Плед флисовый 150×200 Текстиль Сатин 119000
58 TEXT-0013 Полотенце лицевое 50×90 Текстиль Сатин 49000
59 TEXT-0014 Полотенца кухонные 40×60 3 штуки Текстиль Сатин 59000
60 TEXT-0015 Коврик для ванной хлопковый 50×80 Текстиль Ладный дом 99000
61 TEXT-0016 Штора для ванной 180×200 Текстиль Ладный дом 79000
62 TEXT-0017 Тюль вуаль 300×260 Текстиль Ладный дом 199000
63 TEXT-0018 Шторы кухонные 150×170 2 полотна Текстиль Ладный дом 179000
64 TEXT-0019 Скатерть водоотталкивающая 140×180 Текстиль Ладный дом 129000
65 TEXT-0020 Дорожка на стол хлопковая 40×140 Текстиль Ладный дом 59000
66 TEXT-0021 Салфетки столовые льняные 4 штуки Текстиль Сатин 79000
67 TEXT-0022 Чехлы на табурет 35×35 4 штуки Текстиль Ладный дом 109000
68 TEXT-0023 Подушка декоративная 40×40 Текстиль Сатин 69000
69 TEXT-0024 Чехол для диванной подушки 45×45 Текстиль Сатин 39000
70 TEXT-0025 Фартук кухонный с карманом Текстиль Ладный дом 59000
71 TEXT-0026 Прихватки стёганые 20×20 2 штуки Текстиль Ладный дом 39000
72 TEXT-0027 Халат вафельный размер 48–50 Текстиль Сатин 199000
73 TEXT-0028 Комплект полотенец 50×90 и 70×140 Текстиль Сатин 139000
74 TEXT-0029 Одеяло детское байковое 100×140 Текстиль Сатин 169000
75 TEXT-0030 Подушка для стула 40×40 Текстиль Ладный дом 59000
76 POSU-0005 Кастрюля эмалированная 3 л Посуда Кухмастер 169000
77 POSU-0006 Ковш нержавеющий с крышкой 1.5 л Посуда Кухмастер 139000
78 POSU-0007 Сотейник литой с крышкой 28 см Посуда Кухмастер 269000
79 POSU-0008 Сковорода блинная 22 см Посуда Кухмастер 149000
80 POSU-0009 Казан алюминиевый 6 л Посуда Кухмастер 299000
81 POSU-0010 Форма для запекания стеклянная 2.4 л Посуда Кухмастер 119000
82 POSU-0011 Противень стальной 37×27 Посуда Кухмастер 79000
83 POSU-0012 Набор форм для выпечки 3 штуки Посуда Кухмастер 139000
84 POSU-0013 Миска нержавеющая 24 см Посуда Кухмастер 69000
85 POSU-0014 Дуршлаг стальной с ручками 22 см Посуда Кухмастер 89000
86 POSU-0015 Тёрка четырёхгранная 23 см Посуда Кухмастер 49000
87 POSU-0016 Доска разделочная бамбуковая 35×25 Посуда Кухмастер 79000
88 POSU-0017 Набор ножей с подставкой 6 предметов Посуда Кухмастер 249000
89 POSU-0018 Половник и шумовка 2 предмета Посуда Кухмастер 59000
90 POSU-0019 Контейнеры пищевые 7 предметов Посуда Кухмастер 99000
91 POSU-0020 Банки для сыпучих продуктов 4 штуки Посуда Ладья 139000
92 POSU-0021 Тарелки обеденные фарфоровые 6 штук Посуда Ладья 179000
93 POSU-0022 Тарелки суповые керамические 4 штуки Посуда Ладья 129000
94 POSU-0023 Салатник стеклянный 23 см Посуда Ладья 69000
95 POSU-0024 Кружки керамические 350 мл 4 штуки Посуда Ладья 119000
96 POSU-0025 Стаканы высокие 400 мл 6 штук Посуда Ладья 99000
97 POSU-0026 Бокалы для воды 300 мл 6 штук Посуда Ладья 149000
98 POSU-0027 Термос стальной 1 л Посуда Ладья 179000
99 POSU-0028 Термокружка с крышкой 450 мл Посуда Ладья 99000
100 POSU-0029 Сахарница фарфоровая 400 мл Посуда Ладья 59000
101 POSU-0030 Маслёнка стеклянная с крышкой Посуда Ладья 69000
102 TECH-0005 Миксер ручной 500 Вт Бытовая техника Электрон 249000
103 TECH-0006 Измельчитель с чашей 1.5 л Бытовая техника Электрон 299000
104 TECH-0007 Стационарный блендер 1.8 л Бытовая техника Электрон 549000
105 TECH-0008 Мясорубка электрическая 1800 Вт Бытовая техника Электрон 799000
106 TECH-0009 Тостер на 2 ломтика 850 Вт Бытовая техника Электрон 299000
107 TECH-0010 Вафельница электрическая 1000 Вт Бытовая техника Электрон 349000
108 TECH-0011 Мультиварка с чашей 5 л Бытовая техника Электрон 699000
109 TECH-0012 Электроплитка на 1 конфорку 1500 Вт Бытовая техника Электрон 249000
110 TECH-0013 Мини-печь с грилем 30 л Бытовая техника Электрон 899000
111 TECH-0014 Микроволновая печь 20 л Бытовая техника Электрон 999000
112 TECH-0015 Сушилка для овощей 5 поддонов Бытовая техника Электрон 499000
113 TECH-0016 Кофемолка электрическая 200 Вт Бытовая техника Электрон 199000
114 TECH-0017 Капельная кофеварка 1.2 л Бытовая техника Электрон 399000
115 TECH-0018 Соковыжималка шнековая 150 Вт Бытовая техника Электрон 799000
116 TECH-0019 Утюг паровой 2400 Вт Бытовая техника Электрон 349000
117 TECH-0020 Швейная машина 12 операций Бытовая техника Электрон 1299000
118 TECH-0021 Пылесос контейнерный 2000 Вт Бытовая техника Электрон 1099000
119 TECH-0022 Пылесос вертикальный аккумуляторный Бытовая техника Электрон 1499000
120 TECH-0023 Электровеник с контейнером 0.5 л Бытовая техника Электрон 699000
121 TECH-0024 Фен с диффузором 2200 Вт Бытовая техника Электрон 299000
122 TECH-0025 Щипцы для волос 45 Вт Бытовая техника Электрон 249000
123 TECH-0026 Машинка для стрижки с 6 насадками Бытовая техника Электрон 399000
124 TECH-0027 Весы напольные до 180 кг Бытовая техника Электрон 199000
125 TECH-0028 Весы кухонные до 5 кг Бытовая техника Электрон 149000
126 TECH-0029 Увлажнитель воздуха 3 л Бытовая техника Электрон 499000
127 TECH-0030 Тепловентилятор керамический 1500 Вт Бытовая техника Электрон 399000
128 KIDS-0004 Кроватка кукольная деревянная 45 см Детские товары Малышок 249000
129 KIDS-0005 Манеж складной 100×100 Детские товары Малышок 799000
130 KIDS-0006 Горшок детский со спинкой Детские товары Малышок 79000
131 KIDS-0007 Ванночка детская 84 см Детские товары Малышок 169000
132 KIDS-0008 Подставка детская двухступенчатая Детские товары Малышок 89000
133 KIDS-0009 Накладка на унитаз с ручками Детские товары Малышок 69000
134 KIDS-0010 Сиденье для купания на присосках Детские товары Малышок 139000
135 KIDS-0011 Пелёнки муслиновые 80×100 3 штуки Детские товары Малышок 129000
136 KIDS-0012 Одеяло для коляски 90×110 Детские товары Малышок 149000
137 KIDS-0013 Нагрудники непромокаемые 5 штук Детские товары Малышок 59000
138 KIDS-0014 Бутылочка с ручками 250 мл Детские товары Малышок 49000
139 KIDS-0015 Поильник непроливайка 300 мл Детские товары Малышок 55000
140 KIDS-0016 Контейнер для пустышки 2 штуки Детские товары Малышок 25000
141 KIDS-0017 Погремушки мягкие 3 штуки Детские товары Малышок 59000
142 KIDS-0018 Кубики развивающие 12 штук Детские товары Малышок 89000
143 KIDS-0019 Пирамидка деревянная 8 колец Детские товары Малышок 79000
144 KIDS-0020 Сортер с геометрическими фигурами Детские товары Малышок 99000
145 KIDS-0021 Конструктор деревянный 50 деталей Детские товары Малышок 139000
146 KIDS-0022 Доска для рисования магнитная 30×40 Детские товары Малышок 129000
147 KIDS-0023 Мольберт детский двусторонний 105 см Детские товары Малышок 299000
148 KIDS-0024 Набор для лепки 12 цветов Детские товары Малышок 69000
149 KIDS-0025 Коврик игровой складной 180×150 Детские товары Малышок 399000
150 KIDS-0026 Палатка игровая с окошками Детские товары Малышок 199000
151 KIDS-0027 Каталка с ручкой и звуком Детские товары Малышок 169000
152 KIDS-0028 Самокат трёхколёсный до 50 кг Детские товары Малышок 299000
153 KIDS-0029 Санки со спинкой и ремнём Детские товары Малышок 349000
154 KIDS-0030 Рюкзак детский объёмом 8 л Детские товары Малышок 129000
155 WEAR-0004 Футболка хлопковая размер 48 Одежда и обувь Сатин 79000
156 WEAR-0005 Майка хлопковая размер 46 Одежда и обувь Сатин 59000
157 WEAR-0006 Лонгслив трикотажный размер 50 Одежда и обувь Сатин 109000
158 WEAR-0007 Водолазка хлопковая размер 44 Одежда и обувь Сатин 119000
159 WEAR-0008 Толстовка с капюшоном размер 52 Одежда и обувь Сатин 199000
160 WEAR-0009 Брюки спортивные размер 50 Одежда и обувь Сатин 169000
161 WEAR-0010 Легинсы хлопковые размер 46 Одежда и обувь Сатин 99000
162 WEAR-0011 Пижама трикотажная размер 48 Одежда и обувь Сатин 179000
163 WEAR-0012 Ночная сорочка хлопковая размер 50 Одежда и обувь Сатин 129000
164 WEAR-0013 Фартук рабочий с тремя карманами Одежда и обувь Сатин 79000
165 WEAR-0014 Перчатки хозяйственные 3 пары Одежда и обувь Ходики 39000
166 WEAR-0015 Варежки вязаные размер 8 Одежда и обувь Ходики 69000
167 WEAR-0016 Шапка вязаная с отворотом размер 56 Одежда и обувь Ходики 89000
168 WEAR-0017 Шарф шерстяной 30×180 Одежда и обувь Ходики 119000
169 WEAR-0018 Ремень текстильный длиной 120 см Одежда и обувь Ходики 79000
170 WEAR-0019 Стельки войлочные 2 пары Одежда и обувь Ходики 39000
171 WEAR-0020 Шнурки круглые 120 см 3 пары Одежда и обувь Ходики 25000
172 WEAR-0021 Кеды текстильные размер 42 Одежда и обувь Ходики 179000
173 WEAR-0022 Кроссовки прогулочные размер 39 Одежда и обувь Ходики 249000
174 WEAR-0023 Ботинки утеплённые размер 41 Одежда и обувь Ходики 349000
175 WEAR-0024 Сапоги резиновые размер 40 Одежда и обувь Ходики 199000
176 WEAR-0025 Полуботинки кожаные размер 43 Одежда и обувь Ходики 399000
177 WEAR-0026 Сандалии с ремешками размер 38 Одежда и обувь Ходики 189000
178 WEAR-0027 Сланцы резиновые размер 41 Одежда и обувь Ходики 79000
179 WEAR-0028 Балетки текстильные размер 37 Одежда и обувь Ходики 149000
180 WEAR-0029 Туфли домашние утеплённые размер 39 Одежда и обувь Ходики 129000
181 WEAR-0030 Галоши садовые размер 42 Одежда и обувь Ходики 99000
+2 -2
View File
@@ -31,8 +31,8 @@ JSON-поле `ecommerce`. Отдельной сущности «визит» в
| 2 | `VisitID` | `UInt64` | `uint64` | `visit_id` | id визита от генератора — эталон лабы: собери сессии сам и сравни | | 2 | `VisitID` | `UInt64` | `uint64` | `visit_id` | id визита от генератора — эталон лабы: собери сессии сам и сравни |
| 3 | `ClientID` | `UInt64` | `uint64` | `client_id` | анонимный id браузера — кука; по хешу от неё таблица шардируется | | 3 | `ClientID` | `UInt64` | `uint64` | `client_id` | анонимный id браузера — кука; по хешу от неё таблица шардируется |
| 4 | `CounterID` | `UInt32` | `uint32` | `counter_id` | id счётчика: на стенде константа, сайт один | | 4 | `CounterID` | `UInt32` | `uint32` | `counter_id` | id счётчика: на стенде константа, сайт один |
| 5 | `EventDate` | `Date` | `datetime64[D]` | `event_date` | дата события; по ней режется партиция | | 5 | `EventDate` | `Date` | `datetime64[D]` | `event_date` | дата события в часовом поясе счётчика; по ней режется партиция. Дату из `UTCEventTime` не выводить: у ночных событий она на сутки другая |
| 6 | `UTCEventTime` | `DateTime` | `datetime64[s]` | `utc_event_time` | время события в UTC — единственная метка времени, как у Метрики | | 6 | `UTCEventTime` | `DateTime` | `datetime64[s]` | `utc_event_time` | время события в UTC — единственная метка времени, как у Метрики; сутки же считаются в поясе счётчика, поэтому `toDate(UTCEventTime)``EventDate` |
| 7 | `ClientTimeZone` | `Int16` | `int16` | `client_timezone` | смещение часового пояса клиента от UTC, в минутах | | 7 | `ClientTimeZone` | `Int16` | `int16` | `client_timezone` | смещение часового пояса клиента от UTC, в минутах |
| 8 | `EventType` | `LowCardinality(String)` | `object` | `event_type` | тип события: pageview, add_to_cart, purchase — добавка стенда, у Метрики такого поля нет | | 8 | `EventType` | `LowCardinality(String)` | `object` | `event_type` | тип события: pageview, add_to_cart, purchase — добавка стенда, у Метрики такого поля нет |
| 9 | `Sign` | `Int8` | `int8` | `sign` | всегда 1: колонка формата, исправлений записей генератор не шлёт | | 9 | `Sign` | `Int8` | `int8` | `sign` | всегда 1: колонка формата, исправлений записей генератор не шлёт |
+105 -7
View File
@@ -55,7 +55,7 @@
торговые счётчики сложатся, когда торговое поведение определит #40. торговые счётчики сложатся, когда торговое поведение определит #40.
- **Состав не замкнут: посетители появляются и затухают.** План состава - **Состав не замкнут: посетители появляются и затухают.** План состава
задаёт календарь появления — у каждого посетителя есть дата первого задаёт календарь появления — у каждого посетителя есть дата первого
визита и профиль возвратов, включая затухание: заметная доля кук дня и профиль возвратов, включая затухание: заметная доля кук
одноразовая, как в живом трафике. Новые посетители появляются на всём одноразовая, как в живом трафике. Новые посетители появляются на всём
протяжении оси: uniq(ClientID) растёт с горизонтом, дневная и накопленная протяжении оси: uniq(ClientID) растёт с горизонтом, дневная и накопленная
аудитории не сходятся в одно число. Приток — часть плана, а не мутация: аудитории не сходятся в одно число. Приток — часть плана, а не мутация:
@@ -67,7 +67,7 @@
ветвящийся по номеру дня (позиция в дереве — раздел 2), отдельный от ветвящийся по номеру дня (позиция в дереве — раздел 2), отдельный от
подпотока дня-функции. Аудитория дня — когорта D плюс возвраты когорт подпотока дня-функции. Аудитория дня — когорта D плюс возвраты когорт
последних дней: окно активности — хвост возвратов (константа мира — последних дней: окно активности — хвост возвратов (константа мира —
раздел 9), отсчитанный от первого визита человека и общий на обе его раздел 9), отсчитанный от первого дня человека и общий на обе его
куки (уточнение при исполнении #38, 2026-08-02: окно от рождения куки (уточнение при исполнении #38, 2026-08-02: окно от рождения
каждой куки растянуло бы жизнь когорты вдвое, а с ней и загляд назад, каждой куки растянуло бы жизнь когорты вдвое, а с ней и загляд назад,
которым ленивая форма и держится). За краем окна кука не которым ленивая форма и держится). За краем окна кука не
@@ -89,7 +89,7 @@
Равные шансы по всему окну означали бы вторую куку у давно ушедшего Равные шансы по всему окну означали бы вторую куку у давно ушедшего
человека — и вчетверо меньше пар, реализованных внутри снимка. человека — и вчетверо меньше пар, реализованных внутри снимка.
Каждой паре план назначает дни гарантированных заказов: по Каждой паре план назначает дни гарантированных заказов: по
одному на куку, из дней визитов этой куки, на оси от D0 и позже; одному на куку, из дней активности этой куки, на оси от D0 и позже;
человеку предыстории, чьё окно активности таких дней не оставляет, человеку предыстории, чьё окно активности таких дней не оставляет,
пара не назначается. День-функция обязана назначенные заказы пара не назначается. День-функция обязана назначенные заказы
реализовать; остальные покупки — вольные, их решает генератор торговых реализовать; остальные покупки — вольные, их решает генератор торговых
@@ -405,12 +405,13 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
Решено при исполнении #38 (2026-08-02): Решено при исполнении #38 (2026-08-02):
- **Числа притока и состава.** Приток — ~3 800 новых кук в средний день, - **Числа притока и состава.** Приток — ~3 800 новых кук в средний день,
модулируется тем же недельным профилем, что трафик (иначе доля модулируется недельным профилем мира; трафик наследует эту волну через
новичков скакала бы по дням недели). Доля одноразовых кук — 75%; дневную аудиторию, а не отдельным умножением (уточнение при исполнении
#39 — ниже). Доля одноразовых кук — 75%;
возвращающиеся — в среднем 3–4 возврата, профиль убывающий: почти все возвращающиеся — в среднем 3–4 возврата, профиль убывающий: почти все
в первые 7–10 дней, тонкий хвост поздних возвратов и повторных в первые 7–10 дней, тонкий хвост поздних возвратов и повторных
покупок — до края окна (цикл повторной покупки магазина — месяцы). покупок — до края окна (цикл повторной покупки магазина — месяцы).
Хвост возвратов — окно активности человека от его первого визита, Хвост возвратов — окно активности человека от его первого дня,
общее на обе его куки, — и глубина предыстории: 90 дней (решение общее на обе его куки, — и глубина предыстории: 90 дней (решение
владельца 2026-08-02: дольше квартала стенд никто не гоняет, а заказы владельца 2026-08-02: дольше квартала стенд никто не гоняет, а заказы
старых посетителей продолжаются весь прогон; плата — чуть меньше пар, старых посетителей продолжаются весь прогон; плата — чуть меньше пар,
@@ -447,6 +448,100 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
смыслу равен модулю, но платит загрузчиком и валидацией (довод смыслу равен модулю, но платит загрузчиком и валидацией (довод
раздела 3 против YAML). раздела 3 против YAML).
Решено при исполнении #39 (2026-08-02) — решения владельца до реализации:
- **Каталог товаров заводится здесь, а не в #40.** У карточки товара есть
`Title` — имя товара, а имена живут в каталоге: карточка без каталога
невозможна, и без карточек #39 сделал бы примерно половину трафика.
Файл `data/catalog/products.csv` (`sku`, `name`, `category`, `brand`,
`price`) — общий у генератора и словаря ClickHouse; #40 получает готовый.
Решена форма, а не длина: артикул — четыре буквы категории и четыре
цифры, цена — целые копейки, категорий шесть. Строки дописываются
механически, поэтому ни код, ни тесты их не считают, а товар для карточки
выбирается равномерно внутри категории.
- **Ассортимент — непродовольственная розница** (товары для дома, текстиль,
посуда, мелкая бытовая техника, детское, одежда и обувь), ~150200 sku.
Довод не вкусовой: числа мира приняты под этот профиль — цикл повторной
покупки в месяцы, 75% одноразовых кук, конверсия ~2% на визит.
Продуктовая сеть требовала бы других чисел, то есть переоткрытия #38.
- **География — один регион присутствия**: Поволжье с центром в Самаре,
города своего региона и тонкий хвост остальной страны. «Топ городов
России» дал бы магазину с одним складом карту, которой у него не бывает.
- **Модельные сутки считаются в часовом поясе счётчика** (UTC+4), как в
выгрузке Метрики: `EventDate` — дата в поясе счётчика, `UTCEventTime`
абсолютная метка. Шов суток приходится на местную полночь и не режет
утренние сессии, а регион мира выбирается свободно. Суточная волна задана
в местном времени посетителя: гостю из другого пояса профиль
поворачивается на разницу, и пик слегка размазывается — как в жизни.
Следствие для соседних этапов: `toDate(UTCEventTime)``EventDate`; оно
записано в описание выгрузки, иначе сторона хранилища выведет дату сама и
разойдётся на несколько часов данных.
- **Паспорт куки — постоянная часть мира, а не поведение дня.** Браузер, ОС,
устройство, экран и город у куки одни во всех её днях: кука — это браузер
на устройстве. Держит их план состава (`Cohort` и `DayAudience` прирастают
двумя массивами), разворачивает в поля события день-функция. Выводить
паспорт арифметикой из `ClientID` дешевле, но про двухкуковые пары знает
только план, а два города у одного человека — ложь в данных; пара
получает один город и разные устройства («телефон и ноутбук»,
мастер-спека, раздел 5). Броски паспорта приписаны последними, поэтому
измеренные числа канонического мира не сдвинулись: прогон счётчиков до и
после дал те же 3 828 / 6 2357 124 / 68 119 / 170.
- **Справочники — таблицы-литералы** рядом с конфигурацией мира; Faker и
mimesis не подключаются. Нужны не случайные строки, а связки (город → id
региона → часовой пояс; телефон → Safari → iOS → размер экрана) — их
фейкер не даёт, таблицу пришлось бы написать всё равно, а новая
зависимость молча меняла бы мир при обновлении своих словарей.
- **Гео-id — настоящие числа геобазы Яндекса.** Проверка 2026-08-02: id
подтверждались обращением к живым сервисам Яндекса по тому же номеру
(`yandex.ru/pogoda/<id>`, `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) и как он делит режимы - интерфейс запуска генератора (CLI / цели make) и как он делит режимы
@@ -454,4 +549,7 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
оценки мастер-спеки (раздел 9) — и в каком контейнере он живёт (#41); оценки мастер-спеки (раздел 9) — и в каком контейнере он живёт (#41);
- как фиксируется «зерновой» мир конца этапа 2 (раздел 9 мастер-спеки): - как фиксируется «зерновой» мир конца этапа 2 (раздел 9 мастер-спеки):
с манифестным решением напрашивается мини-манифест зернового мира — с манифестным решением напрашивается мини-манифест зернового мира —
форма за #42. форма за #42;
- паспорт мира в манифесте (зерно и версия генератора) файл каталога не
накрывает: правка цены в CSV меняет мир молча. Манифесту нужен хеш
каталога — хвост для #42.
+45 -6
View File
@@ -4,8 +4,35 @@
образцу облачной выгрузки Яндекс Метрики. Устройство и принятые решения — образцу облачной выгрузки Яндекс Метрики. Устройство и принятые решения —
спека [«Генератор (этап 2)»](../docs/specs/2026-08-01-generator.md). спека [«Генератор (этап 2)»](../docs/specs/2026-08-01-generator.md).
Пока здесь контракт схемы события и план состава мира — событий генератор ещё События уже есть: день-функция отдаёт по паре (зерно, D) упорядоченный поток
не порождает. pageview. Торговые события и запуск снаружи — за следующими тикетами.
## Как это работает
> Раздел черновой. По сути он верен, но на понятность читателем со стороны не
> выверен: пишет его тот, кто держит устройство генератора в голове, а это
> худший судья понятности. Выверка — вместе с путеводителем, тикет #48.
Цепочка одна и всегда та же: корневое зерно → подпотоки по позиции в дереве
→ когорта дня (кто впервые пришёл, сколько раз вернётся, чей паспорт какой)
→ аудитория дня, то есть когорта плюс возвраты когорт окна → визиты этих кук
→ строки событий. Мир при этом ленив: когорта считается по требованию, а до
D0 живёт предыстория, поэтому любой день собирается сам по себе — прожитая
история для него не нужна и на него не влияет.
Главное правило для того, кто придёт следующим: **внутри подпотока порядок
бросков — часть контракта.** Броски раздаёт один генератор подряд, и k-й
бросок достаётся тому, кто спросил k-м. Приписать новый бросок в конец
функции безопасно: у прежних он ничего не отнимает. Вставить в середину —
значит сдвинуть все броски после него, а с ними и весь мир: события того же
дня станут другими, счётчики канонического мира разойдутся с манифестом, и
поймается это не ошибкой, а красным чеком. Ровно поэтому паспорта кук в
`plan.cohort` бросаются последними.
Случайность целочисленная — только диапазоны и выбор по целым весам: готовые
распределения numpy расходятся между версиями и архитектурами, а обещано
побайтовое совпадение (спека, раздел 2). Отсюда `weights.py` вместо
`rng.choice` с вероятностями.
## Что где лежит ## Что где лежит
@@ -14,16 +41,28 @@
- `src/clickstream_generator/seeds.py` — иерархия зёрен: кто из какого - `src/clickstream_generator/seeds.py` — иерархия зёрен: кто из какого
подпотока берёт случайность. На ней держится весь детерминизм. подпотока берёт случайность. На ней держится весь детерминизм.
- `src/clickstream_generator/plan.py` — план состава: кто есть в мире в - `src/clickstream_generator/plan.py` — план состава: кто есть в мире в
день D. Когорты, приток, двухкуковые пары и счётчики — до генерации день D. Когорты, приток, двухкуковые пары, паспорта кук и счётчики — до
событий. генерации событий.
- `src/clickstream_generator/weights.py` — выбор по целым весам: один приём
на весь генератор, чтобы дисциплина целочисленной случайности не жила
копиями.
- `src/clickstream_generator/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.py` — контракт схемы: чистые данные о
колонках выгрузки. Собственность генератора; из него выводятся сам колонках выгрузки. Собственность генератора; из него выводятся сам
генератор, его валидация и описание выгрузки в доках. генератор, его валидация и описание выгрузки в доках.
- `src/clickstream_generator/schema_doc.py` — сборка «описания выгрузки» - `src/clickstream_generator/schema_doc.py` — сборка «описания выгрузки»
([`docs/formats/clickstream-event.md`](../docs/formats/clickstream-event.md)) ([`docs/formats/clickstream-event.md`](../docs/formats/clickstream-event.md))
из контракта. Документ руками не правят — пересобирают. из контракта. Документ руками не правят — пересобирают.
- `tests/` — инварианты контракта, свежесть описания и обещания плана: - `tests/` — инварианты контракта, свежесть описания и обещания мира:
чистота от зерна, приток, гарантия двухкуковых пар. чистота от зерна, приток, гарантия двухкуковых пар, форма суточной волны
и сборка визитов по задокументированным правилам.
## Команды ## Команды
@@ -3,4 +3,8 @@
Сердце пакета контракт схемы события (`schema`): чистые данные о колонках Сердце пакета контракт схемы события (`schema`): чистые данные о колонках
выгрузки. Из него выводятся сам генератор, его валидация и «описание выгрузки. Из него выводятся сам генератор, его валидация и «описание
выгрузки» в доках (`schema_doc`); сторона хранилища пишется по описанию. выгрузки» в доках (`schema_doc`); сторона хранилища пишется по описанию.
Мир собирается из четырёх слоёв: числа (`world`) и справочники
(`reference`) описывают его, план состава (`plan`) говорит, кто в нём есть в
день D, а день-функция (`day`) проживает этот день событиями.
""" """
@@ -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,
)
+525
View File
@@ -0,0 +1,525 @@
"""День-функция: (зерно, D) → упорядоченный поток событий модельных суток.
Здесь трафиковая половина мира: визиты, страницы, атрибуция, устройство и
гео. Торговые события (#40) сядут на этот же поток и добавят к нему свои
строки; их колонки в pageview присутствуют, но пусты по смыслу «пусто»
всегда пустой массив, пустая строка или 0, а не отсутствие ключа.
День чистая функция зерна и номера дня: одна и та же пара даёт те же
события, а день N+1 не трогает дни 1N. Держится это на подпотоке
`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
+118 -58
View File
@@ -12,8 +12,14 @@
- приток кто и когда впервые появился, и сколько раз вернётся; - приток кто и когда впервые появился, и сколько раз вернётся;
- двухкуковые пары какой человек завёл вторую куку и в какие дни - двухкуковые пары какой человек завёл вторую куку и в какие дни
каждая из двух кук обязана оформить заказ; каждая из двух кук обязана оформить заказ;
- паспорт куки устройство и город: они у куки одни и те же во всех её
днях, а знает об этом только план (у пары один город на двоих);
- счётчики приток по дням, дневная и накопленная аудитория, пары. - счётчики приток по дням, дневная и накопленная аудитория, пары.
Единица дня здесь день активности куки, а не визит: слово «визит»
закреплено за сессией и полем `VisitID`, и визитов у куки за день бывает
несколько. Сколько именно решает день-функция, плану это безразлично.
Случайность тянется целыми числами: диапазоны и выбор по целым весам. Случайность тянется целыми числами: диапазоны и выбор по целым весам.
Плавающие распределения системной математики не зовутся они расходятся Плавающие распределения системной математики не зовутся они расходятся
между версиями numpy и архитектурами CPU, а обещано побайтовое совпадение между версиями numpy и архитектурами CPU, а обещано побайтовое совпадение
@@ -26,8 +32,9 @@ from functools import lru_cache
import numpy as np import numpy as np
from numpy.typing import NDArray 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.seeds import cohort_stream
from clickstream_generator.weights import pick
# Куки живут числами ниже 2^53: выше JSON округляет — тот же довод, что у # Куки живут числами ниже 2^53: выше JSON округляет — тот же довод, что у
# `WatchID` в контракте схемы. Граница не достигается: 2^53 сам уже за ней. # `WatchID` в контракте схемы. Граница не достигается: 2^53 сам уже за ней.
@@ -37,18 +44,39 @@ CLIENT_ID_LIMIT = 2**53
# долю общего веса. # долю общего веса.
_RETURN_COUNT_CUMULATIVE = np.cumsum(world.RETURN_COUNT_WEIGHTS) _RETURN_COUNT_CUMULATIVE = np.cumsum(world.RETURN_COUNT_WEIGHTS)
_RETURN_DELAY_CUMULATIVE = np.cumsum(world.RETURN_DELAY_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) @dataclass(frozen=True, slots=True)
class Cohort: class Cohort:
"""Люди, впервые пришедшие в мир в день `day`, с их куками и визитами. """Люди, впервые пришедшие в мир в день `day`, с их куками и днями.
Куки лежат одним рядом: сначала первые куки людей по одной на человека, Куки лежат одним рядом: сначала первые куки людей по одной на человека,
индексы 0`people`1, затем вторые куки двухкуковых пар. Кука без пары индексы 0`people`1, затем вторые куки двухкуковых пар. Кука без пары
и есть человек целиком. и есть человек целиком.
Визиты плоская таблица «кука день», отсортированная и без повторов: Дни активности плоская таблица «кука день», отсортированная и без
на день у куки приходится не больше одного визита. Все дни лежат в окне повторов: дважды за день кука не появляется. Все дни лежат в окне
активности человека: от `day` до `day` + хвост возвратов. активности человека: от `day` до `day` + хвост возвратов.
""" """
@@ -57,11 +85,14 @@ class Cohort:
client_id: NDArray[np.uint64] client_id: NDArray[np.uint64]
birth_day: NDArray[np.int64] birth_day: NDArray[np.int64]
buyer: NDArray[np.bool_] buyer: NDArray[np.bool_]
visit_cookie: NDArray[np.int64] active_cookie: NDArray[np.int64]
visit_day: NDArray[np.int64] active_day: NDArray[np.int64]
# Пары и назначенные им заказы — по строке на пару, по колонке на куку. # Пары и назначенные им заказы — по строке на пару, по колонке на куку.
pair_cookies: NDArray[np.int64] pair_cookies: NDArray[np.int64]
pair_order_days: NDArray[np.int64] pair_order_days: NDArray[np.int64]
# Паспорт куки: номер профиля устройства и номер города в справочниках.
device: NDArray[np.int64]
city: NDArray[np.int64]
def __post_init__(self) -> None: def __post_init__(self) -> None:
"""Когорта запоминается, поэтому массивы отдаются только на чтение. """Когорта запоминается, поэтому массивы отдаются только на чтение.
@@ -87,28 +118,36 @@ class Cohort:
"""Сколько пар получили назначенные заказы.""" """Сколько пар получили назначенные заказы."""
return len(self.pair_cookies) return len(self.pair_cookies)
def visitors_on( def visitors_on(self, day: int) -> DayAudience:
self, day: int """Кто из когорты пришёл в день `day` — её доля дневной аудитории.
) -> tuple[NDArray[np.uint64], NDArray[np.bool_], NDArray[np.bool_]]:
"""Кто из когорты пришёл в день `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] 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) @dataclass(frozen=True, slots=True)
class DayAudience: class DayAudience:
"""Куки, пришедшие в день `day`, — вход для будущей дня-функции.""" """Куки, пришедшие в день `day`, — вход дня-функции."""
day: int day: int
client_id: NDArray[np.uint64] client_id: NDArray[np.uint64]
buyer: NDArray[np.bool_] buyer: NDArray[np.bool_]
# Куки, которым план назначил на этот день гарантированный заказ пары. # Куки, которым план назначил на этот день гарантированный заказ пары.
assigned_order: NDArray[np.bool_] assigned_order: NDArray[np.bool_]
# Паспорт куки: номера строк в справочниках устройств и городов.
device: NDArray[np.int64]
city: NDArray[np.int64]
@dataclass(frozen=True, slots=True) @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) active_cookie, active_day = _active_days(
pair_cookies, pair_order_days = _assign_orders( rng, birth_day, day + world.RETURN_TAIL_DAYS
rng,
np.column_stack((paired, np.arange(people, cookies, dtype=np.int64))),
visit_cookie,
visit_day,
cookies,
) )
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( return Cohort(
day=day, day=day,
people=people, people=people,
@@ -165,10 +206,12 @@ def cohort(seed: int, day: int) -> Cohort:
birth_day=birth_day, birth_day=birth_day,
# Вторая кука принадлежит покупателю — как и первая кука его пары. # Вторая кука принадлежит покупателю — как и первая кука его пары.
buyer=np.concatenate((buyer, np.ones(paired.size, dtype=bool))), buyer=np.concatenate((buyer, np.ones(paired.size, dtype=bool))),
visit_cookie=visit_cookie, active_cookie=active_cookie,
visit_day=visit_day, active_day=active_day,
pair_cookies=pair_cookies, pair_cookies=pair_cookies,
pair_order_days=pair_order_days, pair_order_days=pair_order_days,
device=device,
city=city,
) )
@@ -177,18 +220,18 @@ def audience(seed: int, day: int) -> DayAudience:
if day < 0: if day < 0:
raise ValueError(f"события начинаются в D0: дня {day} на оси нет") raise ValueError(f"события начинаются в D0: дня {day} на оси нет")
client_id, buyer, assigned = [], [], []
# Предыстория ровно такой глубины, чтобы окна хватило и первому дню оси. # Предыстория ровно такой глубины, чтобы окна хватило и первому дню оси.
for born in range(day - world.RETURN_TAIL_DAYS, day + 1): parts = [
came, bought, ordered = cohort(seed, born).visitors_on(day) cohort(seed, born).visitors_on(day)
client_id.append(came) for born in range(day - world.RETURN_TAIL_DAYS, day + 1)
buyer.append(bought) ]
assigned.append(ordered)
return DayAudience( return DayAudience(
day=day, day=day,
client_id=np.concatenate(client_id), client_id=np.concatenate([part.client_id for part in parts]),
buyer=np.concatenate(buyer), buyer=np.concatenate([part.buyer for part in parts]),
assigned_order=np.concatenate(assigned), 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): for born in range(-world.RETURN_TAIL_DAYS, days):
born_cohort = cohort(seed, born) born_cohort = cohort(seed, born)
inside = (born_cohort.visit_day >= 0) & (born_cohort.visit_day < days) inside = (born_cohort.active_day >= 0) & (born_cohort.active_day < days)
daily += np.bincount(born_cohort.visit_day[inside], minlength=days) daily += np.bincount(born_cohort.active_day[inside], minlength=days)
seen.append(born_cohort.client_id[np.unique(born_cohort.visit_cookie[inside])]) seen.append(born_cohort.client_id[np.unique(born_cohort.active_cookie[inside])])
appeared = born_cohort.birth_day[ appeared = born_cohort.birth_day[
(born_cohort.birth_day >= 0) & (born_cohort.birth_day < days) (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)) 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 rng: np.random.Generator, birth_day: NDArray[np.int64], window_end: int
) -> tuple[NDArray[np.int64], NDArray[np.int64]]: ) -> tuple[NDArray[np.int64], NDArray[np.int64]]:
"""Дни визитов каждой куки: день рождения и возвраты, пока окно открыто.""" """Дни активности каждой куки: день рождения и возвраты, пока окно открыто."""
cookies = birth_day.size cookies = birth_day.size
returns = np.zeros(cookies, dtype=np.int64) returns = np.zeros(cookies, dtype=np.int64)
returning = rng.integers(0, 100, cookies) >= world.ONE_SHOT_PERCENT 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) 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)) cookie = np.concatenate((np.arange(cookies, dtype=np.int64), owner))
when = np.concatenate((birth_day, birth_day[owner] + delay)) when = np.concatenate((birth_day, birth_day[owner] + delay))
@@ -247,7 +290,7 @@ def _visits(
order = np.lexsort((when, cookie)) order = np.lexsort((when, cookie))
cookie, when = cookie[order], when[order] cookie, when = cookie[order], when[order]
# Два возврата в один день — один визит: день у куки бывает только один. # Два возврата в один день — один день активности: он у куки бывает один.
first_of_day = np.ones(cookie.size, dtype=bool) first_of_day = np.ones(cookie.size, dtype=bool)
first_of_day[1:] = (cookie[1:] != cookie[:-1]) | (when[1:] != when[:-1]) first_of_day[1:] = (cookie[1:] != cookie[:-1]) | (when[1:] != when[:-1])
return cookie[first_of_day], when[first_of_day] return cookie[first_of_day], when[first_of_day]
@@ -256,32 +299,49 @@ def _visits(
def _assign_orders( def _assign_orders(
rng: np.random.Generator, rng: np.random.Generator,
pair_cookies: NDArray[np.int64], pair_cookies: NDArray[np.int64],
visit_cookie: NDArray[np.int64], active_cookie: NDArray[np.int64],
visit_day: NDArray[np.int64], active_day: NDArray[np.int64],
cookies: int, cookies: int,
) -> tuple[NDArray[np.int64], NDArray[np.int64]]: ) -> tuple[NDArray[np.int64], NDArray[np.int64]]:
"""Дни гарантированных заказов пары: по визиту каждой из двух кук, от D0. """Дни гарантированных заказов пары: по дню каждой из двух кук, от D0.
Человеку предыстории, у чьей куки визитов на оси не осталось, пара не Человеку предыстории, у чьей куки дней на оси не осталось, пара не
назначается: обещать заказ, которого никто не увидит, нечестно. Дни назначается: обещать заказ, которого никто не увидит, нечестно. Дни
берутся той же случайностью, что и всё остальное, в данных условность берутся той же случайностью, что и всё остальное, в данных условность
не видна. не видна.
""" """
on_axis = visit_day >= 0 on_axis = active_day >= 0
counts = np.bincount(visit_cookie[on_axis], minlength=cookies) counts = np.bincount(active_cookie[on_axis], minlength=cookies)
# Визиты отсортированы по куке, а внутри куки — по дню, и дни от D0 идут # Дни отсортированы по куке, а внутри куки — по возрастанию, и дни от D0
# последними. Значит, дни на оси у куки — хвост её блока: от конца блока # идут последними. Значит, дни на оси у куки — хвост её блока: от конца
# назад ровно `counts` визитов. # блока назад ровно `counts` дней.
first_on_axis = np.searchsorted(visit_cookie, np.arange(cookies), "right") - counts first_on_axis = np.searchsorted(active_cookie, np.arange(cookies), "right") - counts
assigned = np.all(counts[pair_cookies] > 0, axis=1) assigned = np.all(counts[pair_cookies] > 0, axis=1)
pairs = pair_cookies[assigned] pairs = pair_cookies[assigned]
chosen = first_on_axis[pairs] + rng.integers(0, counts[pairs]) chosen = first_on_axis[pairs] + rng.integers(0, counts[pairs])
return pairs, visit_day[chosen] return pairs, active_day[chosen]
def _pick( def _passports(
rng: np.random.Generator, cumulative: NDArray[np.int64], size: int rng: np.random.Generator, twins: NDArray[np.int64], cookies: int
) -> NDArray[np.int64]: ) -> tuple[NDArray[np.int64], NDArray[np.int64]]:
"""Выбор по целым весам: куда попал бросок в общий вес, тот вариант и вышел.""" """Устройство и город каждой куки; у пары город один, устройства разные.
return np.searchsorted(cumulative, rng.integers(0, cumulative[-1], size), "right")
Выводить паспорт арифметикой из `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
@@ -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/<id>`, `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
@@ -100,7 +100,9 @@ COLUMNS: tuple[Column, ...] = (
numpy_dtype="datetime64[D]", numpy_dtype="datetime64[D]",
normalized_name="event_date", normalized_name="event_date",
group=ColumnGroup.IDENTIFIERS, group=ColumnGroup.IDENTIFIERS,
comment="дата события; по ней режется партиция", comment="дата события в часовом поясе счётчика; по ней режется"
" партиция. Дату из `UTCEventTime` не выводить: у ночных событий"
" она на сутки другая",
), ),
Column( Column(
name="UTCEventTime", name="UTCEventTime",
@@ -108,7 +110,9 @@ COLUMNS: tuple[Column, ...] = (
numpy_dtype="datetime64[s]", numpy_dtype="datetime64[s]",
normalized_name="utc_event_time", normalized_name="utc_event_time",
group=ColumnGroup.IDENTIFIERS, group=ColumnGroup.IDENTIFIERS,
comment="время события в UTC — единственная метка времени, как у Метрики", comment="время события в UTC — единственная метка времени, как у"
" Метрики; сутки же считаются в поясе счётчика, поэтому"
" `toDate(UTCEventTime)` ≠ `EventDate`",
), ),
Column( Column(
name="ClientTimeZone", name="ClientTimeZone",
@@ -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]
+70 -5
View File
@@ -13,6 +13,16 @@
from datetime import date from datetime import date
# Счётчик стенда: сайт один, номер — константа мира.
COUNTER_ID = 42150607
# Часовой пояс счётчика, минуты от UTC: Самара, UTC+4. Модельные сутки
# считаются в этом поясе, как в выгрузке Метрики: `EventDate` — дата в поясе
# счётчика, `UTCEventTime` — абсолютная метка. Отсюда следствие, о котором
# сторона хранилища должна знать заранее: `toDate(UTCEventTime)` ≠ `EventDate`
# у ночных событий (спека генератора, раздел 9).
COUNTER_TIMEZONE_MINUTES = 240
# D0 — первый день оси модельного времени, понедельник. Реальный календарь в # D0 — первый день оси модельного времени, понедельник. Реальный календарь в
# модели не участвует: дата нужна лишь затем, чтобы дни оси легли в # модели не участвует: дата нужна лишь затем, чтобы дни оси легли в
# `EventDate`/`UTCEventTime` конкретными числами. От даты запуска мир не # `EventDate`/`UTCEventTime` конкретными числами. От даты запуска мир не
@@ -25,9 +35,11 @@ ORIGIN = date(2026, 6, 1)
DAILY_INFLUX = 3_800 DAILY_INFLUX = 3_800
# Недельная волна мира, проценты от среднего: понедельник … воскресенье. # Недельная волна мира, проценты от среднего: понедельник … воскресенье.
# Профиль один на весь мир: по нему идёт приток, по нему же пойдёт трафик # Волна задана притоку — по ней в мир приходят новые люди. Трафик наследует
# дня-функции — иначе доля новичков скакала бы по дням недели. В сумме ровно # её через дневную аудиторию, а не вторым умножением (довод — у суточной
# 700: за неделю средний день остаётся средним. # волны выходного дня), поэтому доля новичков по дням недели ровная, а
# недельный размах трафика выходит мягче притока: ±7% против ±10%. В сумме
# ровно 700: за неделю средний день остаётся средним.
WEEKLY_PROFILE_PERCENT = (105, 108, 107, 105, 95, 88, 92) WEEKLY_PROFILE_PERCENT = (105, 108, 107, 105, 95, 88, 92)
# Разброс притока изо дня в день, проценты: ровный приток выдал бы себя в # Разброс притока изо дня в день, проценты: ровный приток выдал бы себя в
@@ -43,13 +55,13 @@ ONE_SHOT_PERCENT = 75
# притоке 3 800 (спека, разделы 5 и 9). # притоке 3 800 (спека, разделы 5 и 9).
RETURN_COUNT_WEIGHTS = (25, 20, 15, 12, 9, 7, 5, 4, 2, 1) RETURN_COUNT_WEIGHTS = (25, 20, 15, 12, 9, 7, 5, 4, 2, 1)
# Хвост возвратов: окно активности человека от его первого визита, общее на # Хвост возвратов: окно активности человека от его первого дня, общее на
# обе его куки. За краем окна кука не возвращается. Оно же — глубина # обе его куки. За краем окна кука не возвращается. Оно же — глубина
# предыстории: столько когорт живёт до D0, чтобы дневная аудитория была на # предыстории: столько когорт живёт до D0, чтобы дневная аудитория была на
# полке с самого первого дня. # полке с самого первого дня.
RETURN_TAIL_DAYS = 90 RETURN_TAIL_DAYS = 90
# Профиль возвратов по дням от первого визита: почти всё в первую неделю, # Профиль возвратов по дням от первого дня куки: почти всё в первую неделю,
# дальше тонкий хвост до края окна — повторные покупки в магазине случаются # дальше тонкий хвост до края окна — повторные покупки в магазине случаются
# и через месяцы. Профиль затухает к краю, поэтому обрыв на нём в данных не # и через месяцы. Профиль затухает к краю, поэтому обрыв на нём в данных не
# виден. Читается по парам «сколько дней — с каким весом»; дней в сумме # виден. Читается по парам «сколько дней — с каким весом»; дней в сумме
@@ -69,3 +81,56 @@ BUYER_PERCENT = 5
# Такой паре план назначает по гарантированному заказу с каждой куки — на # Такой паре план назначает по гарантированному заказу с каждой куки — на
# этом стоит лаба про склейку личности. # этом стоит лаба про склейку личности.
PAIRED_BUYER_PERCENT = 15 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
+79
View File
@@ -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()
+357
View File
@@ -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)
+51 -21
View File
@@ -11,7 +11,7 @@ import re
import numpy as np import numpy as np
import pytest import pytest
from clickstream_generator import plan, world from clickstream_generator import plan, reference, world
from clickstream_generator.seeds import CANONICAL_SEED from clickstream_generator.seeds import CANONICAL_SEED
# Горизонт эталонного снимка — две недели (спека, раздел 5). # Горизонт эталонного снимка — две недели (спека, раздел 5).
@@ -24,9 +24,9 @@ def fresh_memo():
plan.cohort.cache_clear() plan.cohort.cache_clear()
def visits_of(cohort: plan.Cohort) -> list[tuple[int, int]]: def active_days_of(cohort: plan.Cohort) -> list[tuple[int, int]]:
"""Таблица визитов парами «кука — день», как её видит день-функция.""" """Таблица парами «кука — день активности», как её видит день-функция."""
cookies, days = cohort.visit_cookie.tolist(), cohort.visit_day.tolist() cookies, days = cohort.active_cookie.tolist(), cohort.active_day.tolist()
return list(zip(cookies, days, strict=True)) 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.client_id, right.client_id)
and np.array_equal(left.buyer, right.buyer) and np.array_equal(left.buyer, right.buyer)
and np.array_equal(left.birth_day, right.birth_day) 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.active_cookie, right.active_cookie)
and np.array_equal(left.visit_day, right.visit_day) 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_cookies, right.pair_cookies)
and np.array_equal(left.pair_order_days, right.pair_order_days) and np.array_equal(left.pair_order_days, right.pair_order_days)
and np.array_equal(left.device, right.device)
and np.array_equal(left.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]) @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) cohort = plan.cohort(CANONICAL_SEED, day)
assert cohort.visit_day.min() == day assert cohort.active_day.min() == day
assert cohort.visit_day.max() <= day + world.RETURN_TAIL_DAYS 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) cohort = plan.cohort(CANONICAL_SEED, 0)
cookies = range(cohort.client_id.size) cookies = range(cohort.client_id.size)
born = zip(cookies, cohort.birth_day.tolist(), strict=True) 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) cohort = plan.cohort(CANONICAL_SEED, 0)
visits = visits_of(cohort) days = active_days_of(cohort)
assert visits == sorted(visits) assert days == sorted(days)
assert len(set(visits)) == len(visits) assert len(set(days)) == len(days)
def test_client_ids_are_unique_and_survive_json(): 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]) @pytest.mark.parametrize("day", [-world.RETURN_TAIL_DAYS, -20, 0, 5])
def test_every_pair_orders_from_both_cookies_on_the_axis(day: int): def test_every_pair_orders_from_both_cookies_on_the_axis(day: int):
"""Гарантия двухкуковых: заказ назначен на день визита куки, не раньше D0.""" """Гарантия двухкуковых: заказ назначен на день активности куки, от D0."""
cohort = plan.cohort(CANONICAL_SEED, day) cohort = plan.cohort(CANONICAL_SEED, day)
visits = set(visits_of(cohort)) days_of = set(active_days_of(cohort))
for cookies, days in zip( for cookies, days in zip(
cohort.pair_cookies.tolist(), cohort.pair_order_days.tolist(), strict=True cohort.pair_cookies.tolist(), cohort.pair_order_days.tolist(), strict=True
): ):
assert cookies[0] != cookies[1], "заказы пары — с двух разных кук" assert cookies[0] != cookies[1], "заказы пары — с двух разных кук"
for cookie, order_day in zip(cookies, days, strict=True): for cookie, order_day in zip(cookies, days, strict=True):
assert order_day >= 0 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(): def test_the_daily_audience_matches_the_spec_band():
@@ -244,8 +274,8 @@ def test_plan_arrays_are_whole_numbers():
for array in ( for array in (
cohort.client_id, cohort.client_id,
cohort.birth_day, cohort.birth_day,
cohort.visit_cookie, cohort.active_cookie,
cohort.visit_day, cohort.active_day,
cohort.pair_cookies, cohort.pair_cookies,
cohort.pair_order_days, cohort.pair_order_days,
): ):
+92
View File
@@ -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
+53
View File
@@ -64,3 +64,56 @@ def test_most_returns_land_in_the_first_days():
def test_pairs_are_a_small_part_of_the_cohort(): def test_pairs_are_a_small_part_of_the_cohort():
"""Вторые куки пар добавляют к притоку меньше процента (спека, раздел 9).""" """Вторые куки пар добавляют к притоку меньше процента (спека, раздел 9)."""
assert world.BUYER_PERCENT * world.PAIRED_BUYER_PERCENT < 100 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