feat(generator): торговые события — корзина, покупка, сырой ecommerce

- Зачем:
  - клиентская сторона мира становится целой: без add_to_cart и purchase
    в данных нет ни таксономии событий, ни вложенного JSON, ни денег,
    а метка «покупатель» из плана состава ни на что не влияла (#40).
- Что:
  - добавлен модуль commerce: корзина шире заказа, номер заказа вида
    ГГГГММДД-NNNN, промокод без скидки в сумме, сырой ecommerce через orjson;
  - метка покупателя получила два рычага — долгую жизнь куки в плане и
    свою воронку в дне; CART_PERCENT опущен с 8 до 6, чтобы конверсия
    мира осталась около 2%;
  - часть цен каталога получила копейки: productPrice округляется форматом,
    purchaseRevenue несёт точную сумму — расхождение живёт внутри события;
  - граница суток забирает страницу подтверждения вместе с её покупкой:
    потерь на клиентской стороне этот этап не заводит;
  - решения и перемеренные числа мира записаны в спеку генератора, §9.
- Проверка:
  - make lint && make typecheck && make test — 383 passed (было 353).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-02 19:13:30 +03:00
co-authored by Claude Opus 5
parent bfbaa96696
commit 722dbe22b7
19 changed files with 1591 additions and 210 deletions
+75 -75
View File
@@ -1,60 +1,60 @@
sku,name,category,brand,price
HOME-0001,Комплект органайзеров для шкафа,Товары для дома,Домовит,129000
HOME-0002,Сушилка для белья напольная,Товары для дома,Домовит,249900
HOME-0003,Настольная лампа «Тихий вечер»,Товары для дома,Ладный дом,189000
HOME-0003,Настольная лампа «Тихий вечер»,Товары для дома,Ладный дом,188990
HOME-0004,Набор вешалок 12 штук,Товары для дома,Домовит,79000
TEXT-0001,Комплект постельного белья полутораспальный,Текстиль,Сатин,349000
TEXT-0001,Комплект постельного белья полутораспальный,Текстиль,Сатин,348990
TEXT-0002,Плед-покрывало 200×220,Текстиль,Сатин,279000
TEXT-0003,Полотенце банное 70×140,Текстиль,Сатин,89000
TEXT-0004,Штора блэкаут 200×260,Текстиль,Ладный дом,219000
POSU-0001,Сковорода 26 см с антипригарным покрытием,Посуда,Кухмастер,199000
POSU-0002,Набор кастрюль 3 предмета,Посуда,Кухмастер,459000
POSU-0003,Чайник заварочный стеклянный 1 л,Посуда,Кухмастер,119000
TEXT-0004,Штора блэкаут 200×260,Текстиль,Ладный дом,218990
POSU-0001,Сковорода 26 см с антипригарным покрытием,Посуда,Кухмастер,198990
POSU-0002,Набор кастрюль 3 предмета,Посуда,Кухмастер,458999
POSU-0003,Чайник заварочный стеклянный 1 л,Посуда,Кухмастер,118990
POSU-0004,Набор столовых приборов 24 предмета,Посуда,Ладья,289000
TECH-0001,Электрочайник 1.7 л,Бытовая техника,Электрон,189000
TECH-0001,Электрочайник 1.7 л,Бытовая техника,Электрон,188990
TECH-0002,Блендер погружной 800 Вт,Бытовая техника,Электрон,279000
TECH-0003,Отпариватель ручной,Бытовая техника,Электрон,349000
TECH-0004,Робот-пылесос,Бытовая техника,Электрон,1899000
KIDS-0001,Стульчик для кормления,Детские товары,Малышок,649000
KIDS-0002,Ночник-проектор «Звёзды»,Детские товары,Малышок,129000
KIDS-0003,Набор детской посуды 5 предметов,Детские товары,Малышок,89000
WEAR-0001,Тапочки домашние войлочные,Одежда и обувь,Ходики,99000
KIDS-0002,Ночник-проектор «Звёзды»,Детские товары,Малышок,128990
KIDS-0003,Набор детской посуды 5 предметов,Детские товары,Малышок,88999
WEAR-0001,Тапочки домашние войлочные,Одежда и обувь,Ходики,98990
WEAR-0002,Халат махровый,Одежда и обувь,Сатин,249000
WEAR-0003,Носки хлопковые 5 пар,Одежда и обувь,Ходики,59000
HOME-0005,Корзина для белья 45 л,Товары для дома,Домовит,119000
HOME-0006,Контейнер для хранения 18 л,Товары для дома,Домовит,69000
HOME-0007,Чехол для одежды 60×100,Товары для дома,Ладный дом,39000
HOME-0007,Чехол для одежды 60×100,Товары для дома,Ладный дом,38990
HOME-0008,Коврик придверный резиновый 40×60,Товары для дома,Домовит,59000
HOME-0009,Гладильная доска настольная 73×30,Товары для дома,Ладный дом,149000
HOME-0010,Ведро хозяйственное с крышкой 12 л,Товары для дома,Домовит,49000
HOME-0011,Швабра с отжимом и ведром 8 л,Товары для дома,Домовит,179000
HOME-0010,Ведро хозяйственное с крышкой 12 л,Товары для дома,Домовит,48990
HOME-0011,Швабра с отжимом и ведром 8 л,Товары для дома,Домовит,178990
HOME-0012,Щётка для пола с черенком 120 см,Товары для дома,Домовит,45000
HOME-0013,Совок со щёткой на длинной ручке,Товары для дома,Домовит,79000
HOME-0014,Таз хозяйственный круглый 14 л,Товары для дома,Домовит,39000
HOME-0014,Таз хозяйственный круглый 14 л,Товары для дома,Домовит,38990
HOME-0015,Этажерка для ванной 3 яруса,Товары для дома,Ладный дом,139000
HOME-0016,Полка настенная металлическая 45 см,Товары для дома,Ладный дом,99000
HOME-0017,Зеркало настенное круглое 50 см,Товары для дома,Ладный дом,169000
HOME-0017,Зеркало настенное круглое 50 см,Товары для дома,Ладный дом,168990
HOME-0018,Набор коробок для обуви 6 штук,Товары для дома,Домовит,129000
HOME-0019,Подставка для зонтов металлическая,Товары для дома,Ладный дом,159000
HOME-0019,Подставка для зонтов металлическая,Товары для дома,Ладный дом,158990
HOME-0020,Крючки самоклеящиеся 8 штук,Товары для дома,Домовит,29000
HOME-0021,Верёвка бельевая 20 м,Товары для дома,Домовит,19000
HOME-0022,Прищепки усиленные 24 штуки,Товары для дома,Домовит,25000
HOME-0023,Мешки вакуумные для одежды 5 штук,Товары для дома,Домовит,69000
HOME-0022,Прищепки усиленные 24 штуки,Товары для дома,Домовит,24990
HOME-0023,Мешки вакуумные для одежды 5 штук,Товары для дома,Домовит,68999
HOME-0024,Органайзер подвесной с 12 карманами,Товары для дома,Ладный дом,79000
HOME-0025,Часы настенные круглые 30 см,Товары для дома,Ладный дом,89000
HOME-0025,Часы настенные круглые 30 см,Товары для дома,Ладный дом,88990
HOME-0026,Светильник настольный с прищепкой 7 Вт,Товары для дома,Ладный дом,119000
HOME-0027,Удлинитель бытовой 3 розетки 3 м,Товары для дома,Ладный дом,99000
HOME-0028,Лестница-стремянка 3 ступени,Товары для дома,Домовит,299000
HOME-0029,Ящик для инструментов 16 дюймов,Товары для дома,Домовит,139000
HOME-0029,Ящик для инструментов 16 дюймов,Товары для дома,Домовит,138990
HOME-0030,Набор плечиков для брюк 6 штук,Товары для дома,Домовит,89000
TEXT-0005,Пододеяльник двуспальный 175×215,Текстиль,Сатин,169000
TEXT-0006,Простыня на резинке 160×200,Текстиль,Сатин,149000
TEXT-0006,Простыня на резинке 160×200,Текстиль,Сатин,148990
TEXT-0007,Наволочки хлопковые 50×70 2 штуки,Текстиль,Сатин,79000
TEXT-0008,Одеяло всесезонное 172×205,Текстиль,Сатин,299000
TEXT-0009,Подушка стёганая 50×70,Текстиль,Сатин,129000
TEXT-0009,Подушка стёганая 50×70,Текстиль,Сатин,128990
TEXT-0010,Наматрасник непромокаемый 160×200,Текстиль,Сатин,189000
TEXT-0011,Покрывало стёганое 180×210,Текстиль,Сатин,239000
TEXT-0012,Плед флисовый 150×200,Текстиль,Сатин,119000
TEXT-0011,Покрывало стёганое 180×210,Текстиль,Сатин,238999
TEXT-0012,Плед флисовый 150×200,Текстиль,Сатин,118990
TEXT-0013,Полотенце лицевое 50×90,Текстиль,Сатин,49000
TEXT-0014,Полотенца кухонные 40×60 3 штуки,Текстиль,Сатин,59000
TEXT-0015,Коврик для ванной хлопковый 50×80,Текстиль,Ладный дом,99000
@@ -64,47 +64,47 @@ TEXT-0018,Шторы кухонные 150×170 2 полотна,Текстиль
TEXT-0019,Скатерть водоотталкивающая 140×180,Текстиль,Ладный дом,129000
TEXT-0020,Дорожка на стол хлопковая 40×140,Текстиль,Ладный дом,59000
TEXT-0021,Салфетки столовые льняные 4 штуки,Текстиль,Сатин,79000
TEXT-0022,Чехлы на табурет 35×35 4 штуки,Текстиль,Ладный дом,109000
TEXT-0023,Подушка декоративная 40×40,Текстиль,Сатин,69000
TEXT-0022,Чехлы на табурет 35×35 4 штуки,Текстиль,Ладный дом,108990
TEXT-0023,Подушка декоративная 40×40,Текстиль,Сатин,68999
TEXT-0024,Чехол для диванной подушки 45×45,Текстиль,Сатин,39000
TEXT-0025,Фартук кухонный с карманом,Текстиль,Ладный дом,59000
TEXT-0026,Прихватки стёганые 20×20 2 штуки,Текстиль,Ладный дом,39000
TEXT-0026,Прихватки стёганые 20×20 2 штуки,Текстиль,Ладный дом,38990
TEXT-0027,Халат вафельный размер 48–50,Текстиль,Сатин,199000
TEXT-0028,Комплект полотенец 50×90 и 70×140,Текстиль,Сатин,139000
TEXT-0028,Комплект полотенец 50×90 и 70×140,Текстиль,Сатин,138999
TEXT-0029,Одеяло детское байковое 100×140,Текстиль,Сатин,169000
TEXT-0030,Подушка для стула 40×40,Текстиль,Ладный дом,59000
POSU-0005,Кастрюля эмалированная 3 л,Посуда,Кухмастер,169000
POSU-0006,Ковш нержавеющий с крышкой 1.5 л,Посуда,Кухмастер,139000
POSU-0005,Кастрюля эмалированная 3 л,Посуда,Кухмастер,168990
POSU-0006,Ковш нержавеющий с крышкой 1.5 л,Посуда,Кухмастер,138990
POSU-0007,Сотейник литой с крышкой 28 см,Посуда,Кухмастер,269000
POSU-0008,Сковорода блинная 22 см,Посуда,Кухмастер,149000
POSU-0009,Казан алюминиевый 6 л,Посуда,Кухмастер,299000
POSU-0010,Форма для запекания стеклянная 2.4 л,Посуда,Кухмастер,119000
POSU-0010,Форма для запекания стеклянная 2.4 л,Посуда,Кухмастер,118990
POSU-0011,Противень стальной 37×27,Посуда,Кухмастер,79000
POSU-0012,Набор форм для выпечки 3 штуки,Посуда,Кухмастер,139000
POSU-0013,Миска нержавеющая 24 см,Посуда,Кухмастер,69000
POSU-0013,Миска нержавеющая 24 см,Посуда,Кухмастер,68990
POSU-0014,Дуршлаг стальной с ручками 22 см,Посуда,Кухмастер,89000
POSU-0015,Тёрка четырёхгранная 23 см,Посуда,Кухмастер,49000
POSU-0016,Доска разделочная бамбуковая 35×25,Посуда,Кухмастер,79000
POSU-0017,Набор ножей с подставкой 6 предметов,Посуда,Кухмастер,249000
POSU-0018,Половник и шумовка 2 предмета,Посуда,Кухмастер,59000
POSU-0016,Доска разделочная бамбуковая 35×25,Посуда,Кухмастер,78990
POSU-0017,Набор ножей с подставкой 6 предметов,Посуда,Кухмастер,248990
POSU-0018,Половник и шумовка 2 предмета,Посуда,Кухмастер,58990
POSU-0019,Контейнеры пищевые 7 предметов,Посуда,Кухмастер,99000
POSU-0020,Банки для сыпучих продуктов 4 штуки,Посуда,Ладья,139000
POSU-0021,Тарелки обеденные фарфоровые 6 штук,Посуда,Ладья,179000
POSU-0020,Банки для сыпучих продуктов 4 штуки,Посуда,Ладья,138990
POSU-0021,Тарелки обеденные фарфоровые 6 штук,Посуда,Ладья,178999
POSU-0022,Тарелки суповые керамические 4 штуки,Посуда,Ладья,129000
POSU-0023,Салатник стеклянный 23 см,Посуда,Ладья,69000
POSU-0024,Кружки керамические 350 мл 4 штуки,Посуда,Ладья,119000
POSU-0025,Стаканы высокие 400 мл 6 штук,Посуда,Ладья,99000
POSU-0023,Салатник стеклянный 23 см,Посуда,Ладья,68999
POSU-0024,Кружки керамические 350 мл 4 штуки,Посуда,Ладья,118990
POSU-0025,Стаканы высокие 400 мл 6 штук,Посуда,Ладья,98990
POSU-0026,Бокалы для воды 300 мл 6 штук,Посуда,Ладья,149000
POSU-0027,Термос стальной 1 л,Посуда,Ладья,179000
POSU-0028,Термокружка с крышкой 450 мл,Посуда,Ладья,99000
POSU-0029,Сахарница фарфоровая 400 мл,Посуда,Ладья,59000
POSU-0030,Маслёнка стеклянная с крышкой,Посуда,Ладья,69000
TECH-0005,Миксер ручной 500 Вт,Бытовая техника,Электрон,249000
TECH-0006,Измельчитель с чашей 1.5 л,Бытовая техника,Электрон,299000
POSU-0030,Маслёнка стеклянная с крышкой,Посуда,Ладья,68990
TECH-0005,Миксер ручной 500 Вт,Бытовая техника,Электрон,248990
TECH-0006,Измельчитель с чашей 1.5 л,Бытовая техника,Электрон,298990
TECH-0007,Стационарный блендер 1.8 л,Бытовая техника,Электрон,549000
TECH-0008,Мясорубка электрическая 1800 Вт,Бытовая техника,Электрон,799000
TECH-0009,Тостер на 2 ломтика 850 Вт,Бытовая техника,Электрон,299000
TECH-0010,Вафельница электрическая 1000 Вт,Бытовая техника,Электрон,349000
TECH-0010,Вафельница электрическая 1000 Вт,Бытовая техника,Электрон,348999
TECH-0011,Мультиварка с чашей 5 л,Бытовая техника,Электрон,699000
TECH-0012,Электроплитка на 1 конфорку 1500 Вт,Бытовая техника,Электрон,249000
TECH-0013,Мини-печь с грилем 30 л,Бытовая техника,Электрон,899000
@@ -121,61 +121,61 @@ TECH-0023,Электровеник с контейнером 0.5 л,Бытова
TECH-0024,Фен с диффузором 2200 Вт,Бытовая техника,Электрон,299000
TECH-0025,Щипцы для волос 45 Вт,Бытовая техника,Электрон,249000
TECH-0026,Машинка для стрижки с 6 насадками,Бытовая техника,Электрон,399000
TECH-0027,Весы напольные до 180 кг,Бытовая техника,Электрон,199000
TECH-0028,Весы кухонные до 5 кг,Бытовая техника,Электрон,149000
TECH-0027,Весы напольные до 180 кг,Бытовая техника,Электрон,198990
TECH-0028,Весы кухонные до 5 кг,Бытовая техника,Электрон,148990
TECH-0029,Увлажнитель воздуха 3 л,Бытовая техника,Электрон,499000
TECH-0030,Тепловентилятор керамический 1500 Вт,Бытовая техника,Электрон,399000
KIDS-0004,Кроватка кукольная деревянная 45 см,Детские товары,Малышок,249000
KIDS-0005,Манеж складной 100×100,Детские товары,Малышок,799000
TECH-0030,Тепловентилятор керамический 1500 Вт,Бытовая техника,Электрон,398990
KIDS-0004,Кроватка кукольная деревянная 45 см,Детские товары,Малышок,248999
KIDS-0005,Манеж складной 100×100,Детские товары,Малышок,798990
KIDS-0006,Горшок детский со спинкой,Детские товары,Малышок,79000
KIDS-0007,Ванночка детская 84 см,Детские товары,Малышок,169000
KIDS-0007,Ванночка детская 84 см,Детские товары,Малышок,168990
KIDS-0008,Подставка детская двухступенчатая,Детские товары,Малышок,89000
KIDS-0009,Накладка на унитаз с ручками,Детские товары,Малышок,69000
KIDS-0010,Сиденье для купания на присосках,Детские товары,Малышок,139000
KIDS-0010,Сиденье для купания на присосках,Детские товары,Малышок,138990
KIDS-0011,Пелёнки муслиновые 80×100 3 штуки,Детские товары,Малышок,129000
KIDS-0012,Одеяло для коляски 90×110,Детские товары,Малышок,149000
KIDS-0013,Нагрудники непромокаемые 5 штук,Детские товары,Малышок,59000
KIDS-0012,Одеяло для коляски 90×110,Детские товары,Малышок,148990
KIDS-0013,Нагрудники непромокаемые 5 штук,Детские товары,Малышок,58999
KIDS-0014,Бутылочка с ручками 250 мл,Детские товары,Малышок,49000
KIDS-0015,Поильник непроливайка 300 мл,Детские товары,Малышок,55000
KIDS-0015,Поильник непроливайка 300 мл,Детские товары,Малышок,54990
KIDS-0016,Контейнер для пустышки 2 штуки,Детские товары,Малышок,25000
KIDS-0017,Погремушки мягкие 3 штуки,Детские товары,Малышок,59000
KIDS-0018,Кубики развивающие 12 штук,Детские товары,Малышок,89000
KIDS-0019,Пирамидка деревянная 8 колец,Детские товары,Малышок,79000
KIDS-0020,Сортер с геометрическими фигурами,Детские товары,Малышок,99000
KIDS-0019,Пирамидка деревянная 8 колец,Детские товары,Малышок,78990
KIDS-0020,Сортер с геометрическими фигурами,Детские товары,Малышок,98990
KIDS-0021,Конструктор деревянный 50 деталей,Детские товары,Малышок,139000
KIDS-0022,Доска для рисования магнитная 30×40,Детские товары,Малышок,129000
KIDS-0023,Мольберт детский двусторонний 105 см,Детские товары,Малышок,299000
KIDS-0022,Доска для рисования магнитная 30×40,Детские товары,Малышок,128990
KIDS-0023,Мольберт детский двусторонний 105 см,Детские товары,Малышок,298999
KIDS-0024,Набор для лепки 12 цветов,Детские товары,Малышок,69000
KIDS-0025,Коврик игровой складной 180×150,Детские товары,Малышок,399000
KIDS-0026,Палатка игровая с окошками,Детские товары,Малышок,199000
KIDS-0027,Каталка с ручкой и звуком,Детские товары,Малышок,169000
KIDS-0028,Самокат трёхколёсный до 50 кг,Детские товары,Малышок,299000
KIDS-0028,Самокат трёхколёсный до 50 кг,Детские товары,Малышок,298999
KIDS-0029,Санки со спинкой и ремнём,Детские товары,Малышок,349000
KIDS-0030,Рюкзак детский объёмом 8 л,Детские товары,Малышок,129000
WEAR-0004,Футболка хлопковая размер 48,Одежда и обувь,Сатин,79000
WEAR-0005,Майка хлопковая размер 46,Одежда и обувь,Сатин,59000
WEAR-0006,Лонгслив трикотажный размер 50,Одежда и обувь,Сатин,109000
WEAR-0007,Водолазка хлопковая размер 44,Одежда и обувь,Сатин,119000
WEAR-0008,Толстовка с капюшоном размер 52,Одежда и обувь,Сатин,199000
KIDS-0030,Рюкзак детский объёмом 8 л,Детские товары,Малышок,128990
WEAR-0004,Футболка хлопковая размер 48,Одежда и обувь,Сатин,78990
WEAR-0005,Майка хлопковая размер 46,Одежда и обувь,Сатин,58990
WEAR-0006,Лонгслив трикотажный размер 50,Одежда и обувь,Сатин,108990
WEAR-0007,Водолазка хлопковая размер 44,Одежда и обувь,Сатин,118990
WEAR-0008,Толстовка с капюшоном размер 52,Одежда и обувь,Сатин,198999
WEAR-0009,Брюки спортивные размер 50,Одежда и обувь,Сатин,169000
WEAR-0010,Легинсы хлопковые размер 46,Одежда и обувь,Сатин,99000
WEAR-0010,Легинсы хлопковые размер 46,Одежда и обувь,Сатин,98990
WEAR-0011,Пижама трикотажная размер 48,Одежда и обувь,Сатин,179000
WEAR-0012,Ночная сорочка хлопковая размер 50,Одежда и обувь,Сатин,129000
WEAR-0012,Ночная сорочка хлопковая размер 50,Одежда и обувь,Сатин,128999
WEAR-0013,Фартук рабочий с тремя карманами,Одежда и обувь,Сатин,79000
WEAR-0014,Перчатки хозяйственные 3 пары,Одежда и обувь,Ходики,39000
WEAR-0015,Варежки вязаные размер 8,Одежда и обувь,Ходики,69000
WEAR-0016,Шапка вязаная с отворотом размер 56,Одежда и обувь,Ходики,89000
WEAR-0015,Варежки вязаные размер 8,Одежда и обувь,Ходики,68990
WEAR-0016,Шапка вязаная с отворотом размер 56,Одежда и обувь,Ходики,88990
WEAR-0017,Шарф шерстяной 30×180,Одежда и обувь,Ходики,119000
WEAR-0018,Ремень текстильный длиной 120 см,Одежда и обувь,Ходики,79000
WEAR-0019,Стельки войлочные 2 пары,Одежда и обувь,Ходики,39000
WEAR-0018,Ремень текстильный длиной 120 см,Одежда и обувь,Ходики,78990
WEAR-0019,Стельки войлочные 2 пары,Одежда и обувь,Ходики,38990
WEAR-0020,Шнурки круглые 120 см 3 пары,Одежда и обувь,Ходики,25000
WEAR-0021,Кеды текстильные размер 42,Одежда и обувь,Ходики,179000
WEAR-0022,Кроссовки прогулочные размер 39,Одежда и обувь,Ходики,249000
WEAR-0023,Ботинки утеплённые размер 41,Одежда и обувь,Ходики,349000
WEAR-0022,Кроссовки прогулочные размер 39,Одежда и обувь,Ходики,248990
WEAR-0023,Ботинки утеплённые размер 41,Одежда и обувь,Ходики,348990
WEAR-0024,Сапоги резиновые размер 40,Одежда и обувь,Ходики,199000
WEAR-0025,Полуботинки кожаные размер 43,Одежда и обувь,Ходики,399000
WEAR-0025,Полуботинки кожаные размер 43,Одежда и обувь,Ходики,398999
WEAR-0026,Сандалии с ремешками размер 38,Одежда и обувь,Ходики,189000
WEAR-0027,Сланцы резиновые размер 41,Одежда и обувь,Ходики,79000
WEAR-0028,Балетки текстильные размер 37,Одежда и обувь,Ходики,149000
WEAR-0029,Туфли домашние утеплённые размер 39,Одежда и обувь,Ходики,129000
WEAR-0030,Галоши садовые размер 42,Одежда и обувь,Ходики,99000
WEAR-0030,Галоши садовые размер 42,Одежда и обувь,Ходики,98990
1 sku name category brand price
2 HOME-0001 Комплект органайзеров для шкафа Товары для дома Домовит 129000
3 HOME-0002 Сушилка для белья напольная Товары для дома Домовит 249900
4 HOME-0003 Настольная лампа «Тихий вечер» Товары для дома Ладный дом 189000 188990
5 HOME-0004 Набор вешалок 12 штук Товары для дома Домовит 79000
6 TEXT-0001 Комплект постельного белья полутораспальный Текстиль Сатин 349000 348990
7 TEXT-0002 Плед-покрывало 200×220 Текстиль Сатин 279000
8 TEXT-0003 Полотенце банное 70×140 Текстиль Сатин 89000
9 TEXT-0004 Штора блэкаут 200×260 Текстиль Ладный дом 219000 218990
10 POSU-0001 Сковорода 26 см с антипригарным покрытием Посуда Кухмастер 199000 198990
11 POSU-0002 Набор кастрюль 3 предмета Посуда Кухмастер 459000 458999
12 POSU-0003 Чайник заварочный стеклянный 1 л Посуда Кухмастер 119000 118990
13 POSU-0004 Набор столовых приборов 24 предмета Посуда Ладья 289000
14 TECH-0001 Электрочайник 1.7 л Бытовая техника Электрон 189000 188990
15 TECH-0002 Блендер погружной 800 Вт Бытовая техника Электрон 279000
16 TECH-0003 Отпариватель ручной Бытовая техника Электрон 349000
17 TECH-0004 Робот-пылесос Бытовая техника Электрон 1899000
18 KIDS-0001 Стульчик для кормления Детские товары Малышок 649000
19 KIDS-0002 Ночник-проектор «Звёзды» Детские товары Малышок 129000 128990
20 KIDS-0003 Набор детской посуды 5 предметов Детские товары Малышок 89000 88999
21 WEAR-0001 Тапочки домашние войлочные Одежда и обувь Ходики 99000 98990
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 38990
27 HOME-0008 Коврик придверный резиновый 40×60 Товары для дома Домовит 59000
28 HOME-0009 Гладильная доска настольная 73×30 Товары для дома Ладный дом 149000
29 HOME-0010 Ведро хозяйственное с крышкой 12 л Товары для дома Домовит 49000 48990
30 HOME-0011 Швабра с отжимом и ведром 8 л Товары для дома Домовит 179000 178990
31 HOME-0012 Щётка для пола с черенком 120 см Товары для дома Домовит 45000
32 HOME-0013 Совок со щёткой на длинной ручке Товары для дома Домовит 79000
33 HOME-0014 Таз хозяйственный круглый 14 л Товары для дома Домовит 39000 38990
34 HOME-0015 Этажерка для ванной 3 яруса Товары для дома Ладный дом 139000
35 HOME-0016 Полка настенная металлическая 45 см Товары для дома Ладный дом 99000
36 HOME-0017 Зеркало настенное круглое 50 см Товары для дома Ладный дом 169000 168990
37 HOME-0018 Набор коробок для обуви 6 штук Товары для дома Домовит 129000
38 HOME-0019 Подставка для зонтов металлическая Товары для дома Ладный дом 159000 158990
39 HOME-0020 Крючки самоклеящиеся 8 штук Товары для дома Домовит 29000
40 HOME-0021 Верёвка бельевая 20 м Товары для дома Домовит 19000
41 HOME-0022 Прищепки усиленные 24 штуки Товары для дома Домовит 25000 24990
42 HOME-0023 Мешки вакуумные для одежды 5 штук Товары для дома Домовит 69000 68999
43 HOME-0024 Органайзер подвесной с 12 карманами Товары для дома Ладный дом 79000
44 HOME-0025 Часы настенные круглые 30 см Товары для дома Ладный дом 89000 88990
45 HOME-0026 Светильник настольный с прищепкой 7 Вт Товары для дома Ладный дом 119000
46 HOME-0027 Удлинитель бытовой 3 розетки 3 м Товары для дома Ладный дом 99000
47 HOME-0028 Лестница-стремянка 3 ступени Товары для дома Домовит 299000
48 HOME-0029 Ящик для инструментов 16 дюймов Товары для дома Домовит 139000 138990
49 HOME-0030 Набор плечиков для брюк 6 штук Товары для дома Домовит 89000
50 TEXT-0005 Пододеяльник двуспальный 175×215 Текстиль Сатин 169000
51 TEXT-0006 Простыня на резинке 160×200 Текстиль Сатин 149000 148990
52 TEXT-0007 Наволочки хлопковые 50×70 2 штуки Текстиль Сатин 79000
53 TEXT-0008 Одеяло всесезонное 172×205 Текстиль Сатин 299000
54 TEXT-0009 Подушка стёганая 50×70 Текстиль Сатин 129000 128990
55 TEXT-0010 Наматрасник непромокаемый 160×200 Текстиль Сатин 189000
56 TEXT-0011 Покрывало стёганое 180×210 Текстиль Сатин 239000 238999
57 TEXT-0012 Плед флисовый 150×200 Текстиль Сатин 119000 118990
58 TEXT-0013 Полотенце лицевое 50×90 Текстиль Сатин 49000
59 TEXT-0014 Полотенца кухонные 40×60 3 штуки Текстиль Сатин 59000
60 TEXT-0015 Коврик для ванной хлопковый 50×80 Текстиль Ладный дом 99000
64 TEXT-0019 Скатерть водоотталкивающая 140×180 Текстиль Ладный дом 129000
65 TEXT-0020 Дорожка на стол хлопковая 40×140 Текстиль Ладный дом 59000
66 TEXT-0021 Салфетки столовые льняные 4 штуки Текстиль Сатин 79000
67 TEXT-0022 Чехлы на табурет 35×35 4 штуки Текстиль Ладный дом 109000 108990
68 TEXT-0023 Подушка декоративная 40×40 Текстиль Сатин 69000 68999
69 TEXT-0024 Чехол для диванной подушки 45×45 Текстиль Сатин 39000
70 TEXT-0025 Фартук кухонный с карманом Текстиль Ладный дом 59000
71 TEXT-0026 Прихватки стёганые 20×20 2 штуки Текстиль Ладный дом 39000 38990
72 TEXT-0027 Халат вафельный размер 48–50 Текстиль Сатин 199000
73 TEXT-0028 Комплект полотенец 50×90 и 70×140 Текстиль Сатин 139000 138999
74 TEXT-0029 Одеяло детское байковое 100×140 Текстиль Сатин 169000
75 TEXT-0030 Подушка для стула 40×40 Текстиль Ладный дом 59000
76 POSU-0005 Кастрюля эмалированная 3 л Посуда Кухмастер 169000 168990
77 POSU-0006 Ковш нержавеющий с крышкой 1.5 л Посуда Кухмастер 139000 138990
78 POSU-0007 Сотейник литой с крышкой 28 см Посуда Кухмастер 269000
79 POSU-0008 Сковорода блинная 22 см Посуда Кухмастер 149000
80 POSU-0009 Казан алюминиевый 6 л Посуда Кухмастер 299000
81 POSU-0010 Форма для запекания стеклянная 2.4 л Посуда Кухмастер 119000 118990
82 POSU-0011 Противень стальной 37×27 Посуда Кухмастер 79000
83 POSU-0012 Набор форм для выпечки 3 штуки Посуда Кухмастер 139000
84 POSU-0013 Миска нержавеющая 24 см Посуда Кухмастер 69000 68990
85 POSU-0014 Дуршлаг стальной с ручками 22 см Посуда Кухмастер 89000
86 POSU-0015 Тёрка четырёхгранная 23 см Посуда Кухмастер 49000
87 POSU-0016 Доска разделочная бамбуковая 35×25 Посуда Кухмастер 79000 78990
88 POSU-0017 Набор ножей с подставкой 6 предметов Посуда Кухмастер 249000 248990
89 POSU-0018 Половник и шумовка 2 предмета Посуда Кухмастер 59000 58990
90 POSU-0019 Контейнеры пищевые 7 предметов Посуда Кухмастер 99000
91 POSU-0020 Банки для сыпучих продуктов 4 штуки Посуда Ладья 139000 138990
92 POSU-0021 Тарелки обеденные фарфоровые 6 штук Посуда Ладья 179000 178999
93 POSU-0022 Тарелки суповые керамические 4 штуки Посуда Ладья 129000
94 POSU-0023 Салатник стеклянный 23 см Посуда Ладья 69000 68999
95 POSU-0024 Кружки керамические 350 мл 4 штуки Посуда Ладья 119000 118990
96 POSU-0025 Стаканы высокие 400 мл 6 штук Посуда Ладья 99000 98990
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 68990
102 TECH-0005 Миксер ручной 500 Вт Бытовая техника Электрон 249000 248990
103 TECH-0006 Измельчитель с чашей 1.5 л Бытовая техника Электрон 299000 298990
104 TECH-0007 Стационарный блендер 1.8 л Бытовая техника Электрон 549000
105 TECH-0008 Мясорубка электрическая 1800 Вт Бытовая техника Электрон 799000
106 TECH-0009 Тостер на 2 ломтика 850 Вт Бытовая техника Электрон 299000
107 TECH-0010 Вафельница электрическая 1000 Вт Бытовая техника Электрон 349000 348999
108 TECH-0011 Мультиварка с чашей 5 л Бытовая техника Электрон 699000
109 TECH-0012 Электроплитка на 1 конфорку 1500 Вт Бытовая техника Электрон 249000
110 TECH-0013 Мини-печь с грилем 30 л Бытовая техника Электрон 899000
121 TECH-0024 Фен с диффузором 2200 Вт Бытовая техника Электрон 299000
122 TECH-0025 Щипцы для волос 45 Вт Бытовая техника Электрон 249000
123 TECH-0026 Машинка для стрижки с 6 насадками Бытовая техника Электрон 399000
124 TECH-0027 Весы напольные до 180 кг Бытовая техника Электрон 199000 198990
125 TECH-0028 Весы кухонные до 5 кг Бытовая техника Электрон 149000 148990
126 TECH-0029 Увлажнитель воздуха 3 л Бытовая техника Электрон 499000
127 TECH-0030 Тепловентилятор керамический 1500 Вт Бытовая техника Электрон 399000 398990
128 KIDS-0004 Кроватка кукольная деревянная 45 см Детские товары Малышок 249000 248999
129 KIDS-0005 Манеж складной 100×100 Детские товары Малышок 799000 798990
130 KIDS-0006 Горшок детский со спинкой Детские товары Малышок 79000
131 KIDS-0007 Ванночка детская 84 см Детские товары Малышок 169000 168990
132 KIDS-0008 Подставка детская двухступенчатая Детские товары Малышок 89000
133 KIDS-0009 Накладка на унитаз с ручками Детские товары Малышок 69000
134 KIDS-0010 Сиденье для купания на присосках Детские товары Малышок 139000 138990
135 KIDS-0011 Пелёнки муслиновые 80×100 3 штуки Детские товары Малышок 129000
136 KIDS-0012 Одеяло для коляски 90×110 Детские товары Малышок 149000 148990
137 KIDS-0013 Нагрудники непромокаемые 5 штук Детские товары Малышок 59000 58999
138 KIDS-0014 Бутылочка с ручками 250 мл Детские товары Малышок 49000
139 KIDS-0015 Поильник непроливайка 300 мл Детские товары Малышок 55000 54990
140 KIDS-0016 Контейнер для пустышки 2 штуки Детские товары Малышок 25000
141 KIDS-0017 Погремушки мягкие 3 штуки Детские товары Малышок 59000
142 KIDS-0018 Кубики развивающие 12 штук Детские товары Малышок 89000
143 KIDS-0019 Пирамидка деревянная 8 колец Детские товары Малышок 79000 78990
144 KIDS-0020 Сортер с геометрическими фигурами Детские товары Малышок 99000 98990
145 KIDS-0021 Конструктор деревянный 50 деталей Детские товары Малышок 139000
146 KIDS-0022 Доска для рисования магнитная 30×40 Детские товары Малышок 129000 128990
147 KIDS-0023 Мольберт детский двусторонний 105 см Детские товары Малышок 299000 298999
148 KIDS-0024 Набор для лепки 12 цветов Детские товары Малышок 69000
149 KIDS-0025 Коврик игровой складной 180×150 Детские товары Малышок 399000
150 KIDS-0026 Палатка игровая с окошками Детские товары Малышок 199000
151 KIDS-0027 Каталка с ручкой и звуком Детские товары Малышок 169000
152 KIDS-0028 Самокат трёхколёсный до 50 кг Детские товары Малышок 299000 298999
153 KIDS-0029 Санки со спинкой и ремнём Детские товары Малышок 349000
154 KIDS-0030 Рюкзак детский объёмом 8 л Детские товары Малышок 129000 128990
155 WEAR-0004 Футболка хлопковая размер 48 Одежда и обувь Сатин 79000 78990
156 WEAR-0005 Майка хлопковая размер 46 Одежда и обувь Сатин 59000 58990
157 WEAR-0006 Лонгслив трикотажный размер 50 Одежда и обувь Сатин 109000 108990
158 WEAR-0007 Водолазка хлопковая размер 44 Одежда и обувь Сатин 119000 118990
159 WEAR-0008 Толстовка с капюшоном размер 52 Одежда и обувь Сатин 199000 198999
160 WEAR-0009 Брюки спортивные размер 50 Одежда и обувь Сатин 169000
161 WEAR-0010 Легинсы хлопковые размер 46 Одежда и обувь Сатин 99000 98990
162 WEAR-0011 Пижама трикотажная размер 48 Одежда и обувь Сатин 179000
163 WEAR-0012 Ночная сорочка хлопковая размер 50 Одежда и обувь Сатин 129000 128999
164 WEAR-0013 Фартук рабочий с тремя карманами Одежда и обувь Сатин 79000
165 WEAR-0014 Перчатки хозяйственные 3 пары Одежда и обувь Ходики 39000
166 WEAR-0015 Варежки вязаные размер 8 Одежда и обувь Ходики 69000 68990
167 WEAR-0016 Шапка вязаная с отворотом размер 56 Одежда и обувь Ходики 89000 88990
168 WEAR-0017 Шарф шерстяной 30×180 Одежда и обувь Ходики 119000
169 WEAR-0018 Ремень текстильный длиной 120 см Одежда и обувь Ходики 79000 78990
170 WEAR-0019 Стельки войлочные 2 пары Одежда и обувь Ходики 39000 38990
171 WEAR-0020 Шнурки круглые 120 см 3 пары Одежда и обувь Ходики 25000
172 WEAR-0021 Кеды текстильные размер 42 Одежда и обувь Ходики 179000
173 WEAR-0022 Кроссовки прогулочные размер 39 Одежда и обувь Ходики 249000 248990
174 WEAR-0023 Ботинки утеплённые размер 41 Одежда и обувь Ходики 349000 348990
175 WEAR-0024 Сапоги резиновые размер 40 Одежда и обувь Ходики 199000
176 WEAR-0025 Полуботинки кожаные размер 43 Одежда и обувь Ходики 399000 398999
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 98990
+8 -2
View File
@@ -5,8 +5,10 @@
пересобрать: `make docs`.
Одно событие — одна строка: хит по образцу облачной выгрузки Яндекс Метрики.
Многозначное лежит в параллельных массивах одной длины, плюс одно сырое
JSON-поле `ecommerce`. Отдельной сущности «визит» в выгрузке нет — визиты
Многозначное лежит в параллельных массивах, плюс одно сырое JSON-поле
`ecommerce`. Длина у массивов общая **внутри группы**, а не по всему
событию: `purchase*` — по элементу на заказ (у нас всегда один), `product*`
по элементу на товар. Отдельной сущности «визит» в выгрузке нет — визиты
собирают на стороне хранилища, а `VisitID` дан как эталон для самопроверки.
Имена и типы колонок — стороны источника. Хранилище принимает их как есть и
@@ -21,6 +23,10 @@ JSON-поле `ecommerce`. Отдельной сущности «визит» в
`add_to_cart` несёт один товар, `purchase` — состав заказа и блок
`purchase*`. У остальных событий они пусты.
Деньги: `productPrice` — целые рубли, округление формата. Точная сумма
заказа живёт в `purchaseRevenue` и в сыром `ecommerce`, поэтому пересчитать
выручку по разобранным массивам нельзя — цены каталога бывают с копейками.
Всего колонок: 47.
## Идентификаторы и время
+76 -20
View File
@@ -447,15 +447,18 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
- **D0 = 2026-06-01, понедельник** — решение владельца при нарезке
(2026-08-01). Дата недавняя, чтобы данные первые месяцы выглядели
свежими; привязки к реальному календарю у констант мира всё равно нет.
- **Измерено на собранном плане** (#38, канонический мир, 14 дней):
приток 3 828 кук в день в среднем, дневная аудитория 6 235–7 124 —
обе величины в вилках раздела 5. Накопленная аудитория за снимок —
68 тыс. кук: 53,6 тыс. новыми плюс 14,5 тыс. возвратами предыстории
(при нарезке возвраты предыстории оценили вдвое скромнее — отсюда
ходившая раньше оценка ≈60 тыс.). Пар, у которых оба назначенных
заказа попали внутрь 14 дней, — 170; остальные пары горизонт
- **Измерено на собранном плане** (канонический мир, 14 дней; числа
перемерены при исполнении #40 — рычаг долгой жизни помеченного
покупателя сдвинул состав): приток 3 828 кук в день в среднем, дневная
аудитория 6 637–7 595 — обе величины в вилках раздела 5. Накопленная
аудитория за снимок — 70,2 тыс. кук: 53,6 тыс. новыми плюс 16,6 тыс.
возвратами предыстории (при нарезке возвраты предыстории оценили
скромнее — отсюда ходившая раньше оценка ≈60 тыс.). Пар, у которых оба
назначенных заказа попали внутрь 14 дней, — 192; остальные пары горизонт
переживают, их вторая кука приходит позже. Цифры пересчитываются
прогоном плана, событий для них не нужно.
прогоном плана, событий для них не нужно. До правки #40 те же измерения
давали: аудитория 6 235–7 124, накопленная 68,1 тыс. (14,5 тыс.
возвратами предыстории), 170 пар.
- **Конфигурация мира — модуль чистых данных** рядом с контрактом схемы:
все числа мира в одном месте, написанном как приглашение любопытному
менти крутить. Правка модуля — смена мира: чек манифеста честно
@@ -538,10 +541,14 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
Правила лежат в докстринге `day.py`, и тест собирает визиты по ним
заново, сверяя сборку с `VisitID`.
- **Числа дня**: ≈1,4 визита на день активности куки и ≈4,8 страницы на
визит — 9,5 тыс. визитов и ~45 тыс. pageview в средний день; воронка
8% визитов до корзины → 45% из них до оформления → 55% из них до
подтверждения, то есть конверсия визита ~2%. Итог сложится после
торговых событий (#40) и ляжет в манифест.
визит. Воронка при исполнении #39 была общей для всех: 8% визитов до
корзины → 45% из них до оформления → 55% из них до подтверждения, то есть
конверсия визита ~2% при 9,5 тыс. визитов и ~45 тыс. pageview в средний
день. При исполнении #40 воронка развелась надвое (блок ниже): обычному
посетителю 6% до корзины и те же 45% и 55%, помеченному покупателю — 18%
и 60%. Перемеренный средний день: 9 879 визитов, 47 433 pageview, 4,80
страницы на визит, конверсия визита 2,4%; полные числа — в блоке #40,
оттуда же они лягут в манифест.
- **Недельная волна применяется один раз.** Профиль ведёт приток, трафик
наследует его через дневную аудиторию; измеренный размах трафика — ±7%
против ±10% у притока. Второе умножение удвоило бы недельный размах. Если
@@ -595,15 +602,24 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
воронке (#39); правятся оба. Отличается помеченный на обоих шагах воронки:
и до корзины доходит чаще, и бросает её реже. В жизни различаются оба — кто
пришёл смотреть, тот и кладёт реже, и до конца доводит реже; один шаг дал
бы половину картины. Числа под каждый шаг — калибровка. Границы правки —
вилки раздела 5: дневная
аудитория остаётся в пределах 6–8 тыс., средний день — около 50 тыс.
событий, конверсия визита — около 2%. Расти внутри вилки аудитории
бы половину картины. Числа под каждый шаг — калибровка: помеченному 18% до
корзины и 60% из корзины в оформление против 6% и 45% у обычного
посетителя. Обычного калибровка тоже задела — его доля до корзины опущена
с 8% до 6%, и это единственное число мира, изменённое вне двух рычагов.
Довод: помеченные живут дольше и занимают уже 12% дневной аудитории вместо
5% людей когорты, поэтому при прежних 8% конверсия мира ушла бы к 2,9% —
за вилку. Рычаг метки не должен оплачиваться ростом конверсии, и границы
правки — вилки раздела 5: дневная аудитория остаётся в пределах
6–8 тыс., средний день — около 50 тыс. событий, конверсия визита —
около 2%. Расти внутри вилки аудитории
разрешено: помеченные ходят чаще, и это по-прежнему правдоподобный магазин.
Измеренные числа состава (блок #38 выше) после правки перемеряются и
переписываются; база до правки — в средний день аудитория 6 710, визитов
9 249, событий 44 436, визитов с корзиной 780 (8,4%) и с покупкой 221
(2,4%). Уточнение к разделу 1: вольные покупки по-прежнему решает день, но
Измерено после правки (канонический мир, 14 дней): в средний день
аудитория 7 166, визитов 9 879, событий 49 509, визитов с корзиной 765
(7,7%) и с покупкой 240 (2,4%) — все три вилки выдержаны. База до правки
была: аудитория 6 710, визитов 9 249, событий 44 436, визитов с корзиной
780 (8,4%) и с покупкой 221 (2,4%). Числа состава мира (блок #38 выше)
перемерены тем же прогоном. Уточнение к разделу 1: вольные покупки
по-прежнему решает день, но
случайность в них больше не одинакова для всех. Отклонено: *оставить
монетку* — «покупатель» остаётся именем без следа в данных; *один лифт без
долгой жизни* — довод выше, повторных покупок он почти не прибавляет;
@@ -662,6 +678,46 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
ломал бы его ровно там, ради чего написан. Визит, у которого страница
подтверждения срезана полуночью (1,2% покупающих), покупки не даёт — заказа
не было.
- **Счётчика покупок в плане состава не заводится.** Тикет просил «счётчики
покупок и двухкуковых пар сходятся с планом», но покупок план не считает и
считать не будет: раздел 1 обещает до генерации только состав — посетителей,
приток, пары, — а торговые счётчики складываются из поведения дня. Квота на
покупки заставила бы день добирать до точного числа, и конверсия в данных
вышла бы подозрительно ровной — условность видна там, куда лаба смотрит.
Сходятся пары; покупки проверяются постфактум по сгенерированному дню и по
вилке конверсии (~2% на визит).
Решено при исполнении #40 самой реализацией:
- **Торговый хвост разводит и соседние визиты, а не только сутки.** Правило
резки требует, чтобы визиты одной куки стояли дальше таймаута; считать это
расстояние от последней страницы, когда последним событием визита бывает
покупка, значило отдать таймауту торговый хвост — лаба сессий склеила бы
два визита в один и разошлась бы с `VisitID`. Расстояние считается от
последнего события; поймано тестом сборки визитов.
- **Полночь забирает подтверждение вместе с его покупкой.** Резерв суток
сначала считался только для визитов с обещанным планом заказом, и у
вольной покупки оставалось окно в несколько секунд: страница
подтверждения дожила до потока, а событие покупки за полночь вышло. Это
была бы потеря на клиентской стороне — класс этапов 4 и 6, заведённый по
недосмотру. Теперь запас под торговый хвост есть у всякой страницы
подтверждения: либо она уходит за границу суток вместе с покупкой, либо
остаются обе. Это ровно то правило, которое блок выше называет словами
«визит, у которого страница подтверждения срезана полуночью, покупки не
даёт»; в каноническом мире числа от правки не сдвинулись — случай не
выпал ни разу за 14 дней.
- **Задержка торгового события короче самой короткой паузы между
страницами.** Тогда событие корзины не обгоняет страницу, на которой
посетитель нажал кнопку, и не выходит за полночь: за ним в том же визите
всегда идёт страница, которая полночь пережила.
- **Номера торговых событий — из торгового подпотока.** `WatchID` строки
корзины и покупки рисует `COMMERCE`: возьми их у трафика, и правка
торгового поведения сдвинула бы трафиковые номера. Ряды двух подпотоков
проверяются на непересечение — обещание уникальности держит дедупликацию
при переигровке дня, и предполагать его нельзя.
- **Вариант товара — справочник мира.** Сырой `ecommerce` несёт `variant`,
которого в плоских колонках нет; строки живут в справочниках рядом с
устройствами и городами, а не сочиняются на месте.
Остаётся открытым, за тикетами:
+8 -2
View File
@@ -5,7 +5,8 @@
спека [«Генератор (этап 2)»](../docs/specs/2026-08-01-generator.md).
События уже есть: день-функция отдаёт по паре (зерно, D) упорядоченный поток
pageview. Торговые события и запуск снаружи — за следующими тикетами.
трёх видов — просмотр страницы, корзина, покупка. Клиентская сторона на этом
целая; заказы бэкенда и запуск снаружи — за следующими тикетами.
## Как это работает
@@ -53,7 +54,12 @@ D0 живёт предыстория, поэтому любой день соб
`data/catalog/products.csv`, общего у генератора и словаря ClickHouse.
- `src/clickstream_generator/day.py` — день-функция: визиты, страницы,
атрибуция, устройство и гео. Там же правила резки визитов и шов, на
который сядут торговые события.
который садятся торговые события.
- `src/clickstream_generator/commerce.py` — торговые события: что легло в
корзину, что из этого куплено, деньги, номера заказов и сырой `ecommerce`.
Своя случайность, поэтому правка торговли трафик не двигает.
- `src/clickstream_generator/ids.py` — номера событий: неповторяющиеся и
ниже 2^53. Обещание одно на обе половины дня, поэтому и живёт отдельно.
- `src/clickstream_generator/schema.py` — контракт схемы: чистые данные о
колонках выгрузки. Собственность генератора; из него выводятся сам
генератор, его валидация и описание выгрузки в доках.
+4 -1
View File
@@ -6,7 +6,10 @@ readme = "README.md"
# Верхняя граница — не педантизм: обещание побайтовой воспроизводимости
# держится на зафиксированных версиях (спека генератора, раздел 2).
requires-python = ">=3.14,<3.15"
dependencies = ["numpy>=2"]
dependencies = [
"numpy>=2",
"orjson>=3.11.9",
]
[dependency-groups]
dev = [
@@ -12,6 +12,10 @@
Что решено формой файла, а не его длиной: колонки `sku,name,category,brand,
price`; артикул четыре латинские буквы категории, дефис и четыре цифры;
цена целые копейки (деньги генератор считает целыми, спека, раздел 2).
Часть цен кратна рублю, часть несёт копейки как в обычной рознице
(1 289,90 ). Без копеек урок про Float64 был бы беспредметным: округлять
нечего, и разрыв между `productPrice` и `purchaseRevenue` пришлось бы
выдумывать (спека генератора, раздел 9).
Строк в файле может быть сколько угодно: ни генератор, ни тесты их не
считают, а товар для карточки выбирается равномерно внутри категории.
Популярность товаров не моделируется придумывать вес каждой строке
@@ -0,0 +1,500 @@
"""Торговые события: корзина и заказ на потоке дня.
Здесь торговая половина мира. Событие корзины садится на карточку товара
в жизни его шлёт кнопка на карточке, а не открытие страницы корзины; при
нескольких товарах в заказе иначе его и не разложить: событий столько, со
скольких карточек положили. Событие покупки садится на страницу
подтверждения. Оба встают на несколько секунд позже своей страницы, а
граница модельных суток режет всё, что за неё вышло: визит, у которого
подтверждение срезано полуночью, покупки не даёт заказа не было.
**Корзина шире заказа.** Посетитель кладёт товары тех карточек, которые
открывал в этом визите; карточка, открытая дважды, даёт одну позицию
повторный просмотр это раздумье, а не второй товар. Покупает он не всё:
часть позиций остаётся брошенной. Иначе событие корзины не рассказывало бы
ничего сверх покупки заказ был бы её точной копией, и сравнивать было бы
нечего.
**Деньги считаются целыми копейками.** В колонку `productPrice` ложатся
целые рубли, как у Метрики, а дробное число в событии одно
`purchaseRevenue`. Часть цен каталога несёт копейки, поэтому округление
видно: выручку по разобранным массивам не пересчитать, точная сумма живёт в
`purchaseRevenue` и в сыром `ecommerce`. Разрыв внутри одного события это
настоящий урок формата, а не придуманный.
**Промокод в событии есть, скидки в сумме нет.** Выручка, которую шлёт
клиент, сумма позиций без скидки и доставки: код на сайте знает корзину, а
не итог расчёта. Скидку по коду насчитывает бэкенд (этап 3), и таблица «код
скидка» лежит в числах мира, чтобы обе стороны брали одну.
**Номер заказа читаемый** день модельного времени и порядковый номер
покупки в этом дне (`20260603-0042`). Он же `order_id` бэкенда: по нему
соединяется сверка (мастер-спека, раздел 4). Нумеруются все покупки,
дошедшие до потока дня, в порядке событий до всяких потерь; поэтому номер
присваивается последним ходом, когда поток уже упорядочен.
**Случайность подпоток `COMMERCE`** (спека генератора, раздел 2): правка
торгового поведения не сдвигает трафиковый поток. Броски целые и векторные;
посточно собираются только строки их numpy не умеет.
"""
from dataclasses import dataclass
from typing import Any
import numpy as np
import orjson
from numpy.typing import NDArray
from clickstream_generator import catalog, ids, reference, world
from clickstream_generator.reference import Page
from clickstream_generator.seeds import Component, day_stream
from clickstream_generator.weights import pick
# Таксономия: тип события и действие с товаром. Полный словарь торговых
# событий Метрики (detail, remove, impressions) стенд не берёт.
ADD_TO_CART = "add_to_cart"
PURCHASE = "purchase"
ADD_ACTION = "add"
PURCHASE_ACTION = "purchase"
# Копеек в рубле: внутри генератора деньги целые, в колонках — рубли.
KOPECKS = 100
_QUANTITY_CUMULATIVE = np.cumsum(world.ITEM_QUANTITY_WEIGHTS)
_PRODUCT_COLUMNS = (
"productID",
"productName",
"productCategory",
"productPrice",
"productQuantity",
"productEventType",
)
@dataclass(frozen=True, slots=True)
class _Baskets:
"""Корзины дня: позиции по корзинам и строка подтверждения заказа.
Позиция товар, положенный в корзину: `anchor` строка карточки, на
которую сядет событие, `product` номер товара в каталоге. Позиции
лежат подряд по корзинам, внутри корзины по времени; `basket` говорит,
чья позиция, а `first` и `count` где чей кусок. `confirmation`
отвечает на вопрос, дошла ли корзина до заказа: строка подтверждения
или 1 у брошенной.
"""
anchor: NDArray[np.int64]
product: NDArray[np.int64]
basket: NDArray[np.int64]
first: NDArray[np.int64]
count: NDArray[np.int64]
confirmation: NDArray[np.int64]
def __len__(self) -> int:
return self.confirmation.size
def positions_of(self, basket: int, kept: NDArray[np.bool_]) -> NDArray[np.int64]:
"""Позиции корзины, у которых стоит отметка: например, купленные."""
here = slice(self.first[basket], self.first[basket] + self.count[basket])
return self.first[basket] + np.flatnonzero(kept[here])
@dataclass(frozen=True, slots=True)
class _Draws:
"""Броски торгового подпотока: случайное решается один раз и разом.
По позициям корзин сколько штук берут, какой это вариант товара,
дошла ли позиция до заказа и на сколько секунд событие корзины отстало
от карточки. По корзинам промокод и задержка события покупки.
"""
quantity: NDArray[np.int64]
variant: NDArray[np.int64]
kept: NDArray[np.bool_]
cart_delay: NDArray[np.int64]
coupon: NDArray[np.int64]
order_delay: NDArray[np.int64]
@dataclass(frozen=True, slots=True)
class _Events:
"""Строки одного вида торговых событий, готовые встать в поток.
`raw` сырой `ecommerce` каждой строки ещё объектом: номер заказа в нём
появится, когда поток будет упорядочен, а строка станет байтами один
раз, каноническим сериализатором.
"""
columns: dict[str, NDArray[Any]]
page: NDArray[np.uint8]
product: NDArray[np.int64]
raw: list[dict[str, Any]]
def weave(
seed: int,
day: int,
columns: dict[str, NDArray[Any]],
page: NDArray[np.uint8],
product: NDArray[np.int64],
) -> tuple[dict[str, NDArray[Any]], NDArray[np.uint8], NDArray[np.int64]]:
"""Вплетает торговые события в трафиковый поток и отдаёт поток целиком.
Строки приходят упорядоченными по времени и такими же уходят: торговые
события встают между ними, и поток пересобирается одним порядком.
"""
rng = day_stream(seed, day, Component.COMMERCE)
baskets = _baskets(page, product, columns["VisitID"])
if not len(baskets):
return columns, page, product
goods = catalog.catalog()
draws = _draws(rng, baskets)
events = (
_cart_events(columns, baskets, goods, draws),
_order_events(columns, baskets, goods, draws),
)
return _stream(rng, columns, page, product, events)
def _draws(rng: np.random.Generator, baskets: _Baskets) -> _Draws:
"""Всё случайное в торговых событиях — целыми числами и векторно."""
positions = baskets.product.size
return _Draws(
quantity=1 + pick(rng, _QUANTITY_CUMULATIVE, positions),
variant=rng.integers(0, len(reference.PRODUCT_VARIANTS), positions),
kept=_kept(rng, baskets),
cart_delay=rng.integers(*world.TRADE_DELAY_SECONDS, positions),
coupon=_coupons(rng, len(baskets)),
order_delay=rng.integers(*world.TRADE_DELAY_SECONDS, len(baskets)),
)
def _baskets(
page: NDArray[np.uint8], product: NDArray[np.int64], visit: NDArray[np.uint64]
) -> _Baskets:
"""Что посетитель положил в корзину и дошёл ли до заказа.
Корзина есть у визита, дошедшего до страницы корзины; в ней товары всех
карточек этого визита. Карточка, открытая дважды, даёт одну позицию
остаётся первая: тогда товар и кладут.
"""
order = np.argsort(visit, kind="stable")
page, product, visit = page[order], product[order], visit[order]
# Строки визита лежат подряд, внутри визита — по времени: поток пришёл
# упорядоченным, а сортировка по визиту устойчивая.
started = np.concatenate(([True], visit[1:] != visit[:-1]))
number = np.cumsum(started) - 1
with_cart = number[page == Page.CART]
basket_of_visit = np.full(int(started.sum()), -1, dtype=np.int64)
basket_of_visit[with_cart] = np.arange(with_cart.size)
cards = np.flatnonzero((page == Page.PRODUCT) & (basket_of_visit[number] >= 0))
# Ключ «визит и товар»: `np.unique` отдаёт индексы первых вхождений,
# поэтому вторая карточка того же товара позиции не добавляет.
key = number[cards] * catalog.catalog().sku.size + product[cards]
positions = np.sort(cards[np.unique(key, return_index=True)[1]])
# Подтверждение без корзины невозможно: полночь режет визит с хвоста, а
# корзина в нём раньше подтверждения — поэтому у каждого подтверждения
# корзина есть, и номер её всегда найдётся.
confirmed = np.flatnonzero(page == Page.CONFIRMATION)
confirmation = np.full(with_cart.size, -1, dtype=np.int64)
confirmation[basket_of_visit[number[confirmed]]] = order[confirmed]
basket = basket_of_visit[number[positions]]
count = np.bincount(basket, minlength=with_cart.size)
return _Baskets(
anchor=order[positions],
product=product[positions],
basket=basket,
# Позиции лежат подряд, корзины идут по порядку визитов — поэтому
# начало каждого куска находится накопленной суммой.
first=np.cumsum(count) - count,
count=count,
confirmation=confirmation,
)
def _kept(rng: np.random.Generator, baskets: _Baskets) -> NDArray[np.bool_]:
"""Какие позиции корзины дошли до заказа: часть остаётся брошенной.
Пустым заказ не бывает: если брошены все позиции, одна возвращается
какая, решает свой бросок. Хотя бы одна позиция у корзины есть всегда:
перед корзиной визит обязательно открывал карточку.
"""
kept = (
rng.integers(0, 100, baskets.product.size) >= world.ABANDONED_POSITION_PERCENT
)
rescued = baskets.first + rng.integers(0, baskets.count)
empty = np.bincount(baskets.basket[kept], minlength=len(baskets)) == 0
kept[rescued[empty]] = True
return kept
def _coupons(rng: np.random.Generator, baskets: int) -> NDArray[np.int64]:
"""Промокод корзины: номер строки в таблице кодов или −1, если кода нет."""
code = rng.integers(0, len(world.COUPONS), baskets)
return np.where(rng.integers(0, 100, baskets) < world.COUPON_PERCENT, code, -1)
def _cart_events(
columns: dict[str, NDArray[Any]],
baskets: _Baskets,
goods: catalog.Catalog,
draws: _Draws,
) -> _Events:
"""Строки `add_to_cart`: по одной на каждый положенный товар."""
positions = baskets.product.size
alone = [np.array([position]) for position in range(positions)]
rows = _on_page(columns, baskets.anchor, ADD_TO_CART, draws.cart_delay)
rows["GoalsReached"] = _same(
np.array([world.GOAL_CART_ID], dtype=np.uint32), positions
)
side, blocks = _product_side(goods, baskets, alone, draws, ADD_ACTION)
rows.update(side)
return _Events(
columns=rows,
page=np.full(positions, Page.PRODUCT, dtype=np.uint8),
product=baskets.product.copy(),
raw=[{"currencyCode": world.CURRENCY, ADD_ACTION: block} for block in blocks],
)
def _order_events(
columns: dict[str, NDArray[Any]],
baskets: _Baskets,
goods: catalog.Catalog,
draws: _Draws,
) -> _Events:
"""Строки `purchase`: по одной на корзину, дошедшую до подтверждения."""
ordered = np.flatnonzero(baskets.confirmation >= 0)
bought = [baskets.positions_of(basket, draws.kept) for basket in ordered]
rows = _on_page(
columns, baskets.confirmation[ordered], PURCHASE, draws.order_delay[ordered]
)
rows["GoalsReached"] = _same(
np.array([world.GOAL_PURCHASE_ID], dtype=np.uint32), ordered.size
)
side, blocks = _product_side(goods, baskets, bought, draws, PURCHASE_ACTION)
rows.update(side)
# Выручка клиента — сумма позиций без скидки и доставки, целыми копейками.
kopecks = [
int((goods.price[baskets.product[group]] * draws.quantity[group]).sum())
for group in bought
]
codes = [
world.COUPONS[number][0] if number >= 0 else ""
for number in draws.coupon[ordered].tolist()
]
rows["purchaseRevenue"] = _cells(
[np.array([money / KOPECKS], dtype=np.float64) for money in kopecks]
)
rows["purchaseCurrency"] = _same(
np.array([world.CURRENCY], dtype=object), ordered.size
)
rows["purchaseCoupon"] = _cells([np.array([code], dtype=object) for code in codes])
raw = [
{
"currencyCode": world.CURRENCY,
PURCHASE_ACTION: {"actionField": _action_field(money, code), **block},
}
for money, code, block in zip(kopecks, codes, blocks, strict=True)
]
return _Events(
columns=rows,
page=np.full(ordered.size, Page.CONFIRMATION, dtype=np.uint8),
product=np.full(ordered.size, -1, dtype=np.int64),
raw=raw,
)
def _action_field(kopecks: int, code: str) -> dict[str, Any]:
"""Блок `actionField` заказа: номер, выручка и купон, если он был.
Номер пустой до сборки потока его присваивает `_seal`, когда порядок
событий дня уже известен.
"""
field: dict[str, Any] = {"id": "", "revenue": kopecks / KOPECKS}
if code:
field["coupon"] = code
return field
def _product_side(
goods: catalog.Catalog,
baskets: _Baskets,
groups: list[NDArray[np.int64]],
draws: _Draws,
action: str,
) -> tuple[dict[str, NDArray[Any]], list[dict[str, Any]]]:
"""Массивы `product*` и товарная часть сырого JSON — одним проходом.
Массивы группы `product*` одной длины между собой: по элементу на товар.
С группой `purchase*` они не совпадают и не должны там по элементу на
заказ (мастер-спека, раздел 1).
Сырой JSON несёт больше, чем колонки: бренд, вариант товара и точную
цену с копейками. На этом и стоит лаба «сырое против разобранного»
иначе в сыром лежало бы ровно то же самое.
"""
columns: dict[str, list[NDArray[Any]]] = {name: [] for name in _PRODUCT_COLUMNS}
blocks: list[dict[str, Any]] = []
for group in groups:
numbers = baskets.product[group]
pieces = draws.quantity[group]
columns["productID"].append(np.array(goods.sku[numbers], dtype=object))
columns["productName"].append(np.array(goods.name[numbers], dtype=object))
columns["productCategory"].append(
np.array([_category(goods, number) for number in numbers], dtype=object)
)
# Цена в колонке — целые рубли, как у Метрики: это округление и есть
# тот разрыв, из-за которого выручку по массивам не пересобрать.
columns["productPrice"].append((goods.price[numbers] + KOPECKS // 2) // KOPECKS)
columns["productQuantity"].append(pieces.astype(np.uint64))
columns["productEventType"].append(np.full(group.size, action, dtype=object))
blocks.append(
{
"products": [
{
"id": goods.sku[number],
"name": goods.name[number],
"category": _category(goods, number),
"brand": goods.brand[number],
"variant": reference.PRODUCT_VARIANTS[draws.variant[position]],
"price": int(goods.price[number]) / KOPECKS,
"quantity": int(pieces[place]),
}
for place, (position, number) in enumerate(
zip(group.tolist(), numbers.tolist(), strict=True)
)
]
}
)
return {name: _cells(values) for name, values in columns.items()}, blocks
def _category(goods: catalog.Catalog, number: int) -> str:
"""Имя категории товара — то же, что в файле каталога и в словаре."""
return catalog.CATEGORIES[goods.category[number]].name
def _on_page(
columns: dict[str, NDArray[Any]],
anchor: NDArray[np.int64],
event_type: str,
delay: NDArray[np.int64],
) -> dict[str, NDArray[Any]]:
"""Событие на странице: её колонки целиком, свой тип и своё время.
Страница у торгового события та же, что у просмотра, на который оно
село: тот же адрес и реферер, та же кука, тот же визит, устройство и
гео. Различаются тип, время и торговые колонки их кладёт вызывающий.
"""
rows = {name: value[anchor] for name, value in columns.items()}
rows["EventType"] = np.full(anchor.size, event_type, dtype=object)
rows["UTCEventTime"] = columns["UTCEventTime"][anchor] + delay.astype(
"timedelta64[s]"
)
return rows
def _stream(
rng: np.random.Generator,
columns: dict[str, NDArray[Any]],
page: NDArray[np.uint8],
product: NDArray[np.int64],
events: tuple[_Events, ...],
) -> tuple[dict[str, NDArray[Any]], NDArray[np.uint8], NDArray[np.int64]]:
"""Собирает поток дня целиком: сутки режут хвост, время задаёт порядок."""
traffic = page.size
trade = {
name: np.concatenate([part.columns[name] for part in events])
for name in columns
}
raw = [block for part in events for block in part.raw]
alive = trade["UTCEventTime"] < _midnight(columns) + np.timedelta64(1, "D")
trade = {name: value[alive] for name, value in trade.items()}
# Номера торговых строк — из торгового подпотока: возьми их день у
# трафика, и правка торгового поведения сдвинула бы трафиковые `WatchID`.
trade["WatchID"] = ids.unique_apart_from(rng, int(alive.sum()), columns["WatchID"])
raw = [block for block, here in zip(raw, alive.tolist(), strict=True) if here]
rows = {
name: np.concatenate((value, trade[name])) for name, value in columns.items()
}
page = np.concatenate((page, np.concatenate([part.page for part in events])[alive]))
product = np.concatenate(
(product, np.concatenate([part.product for part in events])[alive])
)
# Чей сырой блок в какой строке: у просмотра страницы блока нет.
place = np.full(traffic + len(raw), -1, dtype=np.int64)
place[traffic:] = np.arange(len(raw))
order = np.lexsort((rows["WatchID"], rows["UTCEventTime"]))
rows = {name: value[order] for name, value in rows.items()}
_seal(rows, raw, place[order], _order_prefix(columns))
return rows, page[order], product[order]
def _seal(
rows: dict[str, NDArray[Any]],
raw: list[dict[str, Any]],
place: NDArray[np.int64],
prefix: str,
) -> None:
"""Раздаёт номера заказов и собирает сырой `ecommerce`.
Номер получают все покупки, дошедшие до потока, в порядке событий до
всяких потерь: этап 6 выбрасывает событие, когда номер уже присвоен,
иначе одна потеря перенумеровала бы чужие заказы и мост к бэкенду
разъехался бы. Строку собирает канонический сериализатор: руками это был
бы второй сериализатор со своим экранированием.
"""
number = 0
for row in np.flatnonzero(place >= 0).tolist():
block = raw[place[row]]
if rows["EventType"][row] == PURCHASE:
number += 1
code = f"{prefix}-{number:04d}"
block[PURCHASE_ACTION]["actionField"]["id"] = code
rows["purchaseID"][row] = np.array([code], dtype=object)
rows["ecommerce"][row] = orjson.dumps(block).decode()
def _midnight(columns: dict[str, NDArray[Any]]) -> np.datetime64:
"""Начало модельных суток абсолютной меткой: полночь в поясе счётчика."""
date: np.datetime64 = columns["EventDate"][0]
return date.astype("datetime64[s]") - np.timedelta64(
world.COUNTER_TIMEZONE_MINUTES, "m"
)
def _order_prefix(columns: dict[str, NDArray[Any]]) -> str:
"""Первая половина номера заказа: день модельного времени, `20260603`."""
return str(columns["EventDate"][0]).replace("-", "")
def _cells(values: list[NDArray[Any]]) -> NDArray[np.object_]:
"""Колонка-массив: в каждой ячейке свой массив своего типа."""
column = np.empty(len(values), dtype=object)
for row, value in enumerate(values):
column[row] = value
return column
def _same(value: NDArray[Any], size: int) -> NDArray[np.object_]:
"""Колонка-массив, у которой во всех ячейках один и тот же массив."""
value.flags.writeable = False
column = np.empty(size, dtype=object)
column.fill(value)
return column
+84 -58
View File
@@ -1,9 +1,10 @@
"""День-функция: (зерно, D) → упорядоченный поток событий модельных суток.
Здесь трафиковая половина мира: визиты, страницы, атрибуция, устройство и
гео. Торговые события (#40) сядут на этот же поток и добавят к нему свои
строки; их колонки в pageview присутствуют, но пусты по смыслу «пусто»
всегда пустой массив, пустая строка или 0, а не отсутствие ключа.
гео. Торговые события садятся на этот же поток и добавляют к нему свои
строки их собирает `commerce` и им же поток заканчивается. У просмотра
страницы торговые колонки присутствуют, но пусты по смыслу: «пусто» всегда
пустой массив, пустая строка или 0, а не отсутствие ключа.
День чистая функция зерна и номера дня: одна и та же пара даёт те же
события, а день N+1 не трогает дни 1N. Держится это на подпотоке
@@ -25,23 +26,25 @@
1. Визит принадлежит одной куке: склейка `ClientID` визитом не считается.
2. Пауза дольше 30 минут рвёт визит надвое, поэтому паузы внутри визита
всегда короче таймаута, а соседние визиты куки разведены дальше него.
всегда короче таймаута, а соседние визиты куки разведены дальше него
считая от последнего события визита, которым бывает покупка, а не от
последней его страницы.
3. Граница модельных суток режет визит: события за полночь в дне не живут.
Исключение одно визит с заказом, обещанным планом двухкуковых пар: его
старт сдвигается назад, чтобы воронка уместилась в сутки. Это принятое
ограничение модели: обещание плана гарантия, ради неё мы сужаем свободу
старта. Цена названа около 24 визитов в день из ~9,5 тыс. не начинаются
в последние минуты суток.
старт сдвигается назад, чтобы воронка уместилась в сутки вместе с
торговым хвостом. Это принятое ограничение модели: обещание плана
гарантия, ради неё мы сужаем свободу старта. Цена названа около 24
визитов в день из ~9,5 тыс. не начинаются в последние минуты суток.
**Шов для торговых событий (#40).** `Day` отдаёт, кроме колонок, два
выровненных по строкам ряда: `page` какая это страница магазина, и
`product` какой товар показывала карточка (1 у прочих страниц). По ним
#40 узнаёт и то, куда вешать событие (корзина, оформление, подтверждение),
и то, что посетитель на самом деле смотрел: товар в корзине, которого никто
не открывал, видимая глупость в воронке. Визит с назначенным заказом
всегда доходит до `/confirmation`, а перед корзиной у него всегда есть
карточка товара. Своей случайности #40 не занимает: подпоток `COMMERCE`
нетронут.
**Шов с торговыми событиями.** Поток несёт, кроме колонок, два выровненных
по строкам ряда: `page` какая это страница магазина, и `product` какой
товар показывала карточка (1 у прочих страниц). По ним `commerce` знает и
то, куда сажать событие (карточка, подтверждение), и то, что посетитель на
самом деле смотрел: товар в корзине, которого никто не открывал, видимая
глупость в воронке. Визит с назначенным заказом всегда доходит до
`/confirmation`, а перед корзиной у него всегда есть карточка товара.
Случайность у половин разная: трафик берёт подпоток `TRAFFIC`, торговля
`COMMERCE`, и правка одной не сдвигает другую.
"""
from dataclasses import dataclass
@@ -50,21 +53,24 @@ from typing import Any
import numpy as np
from numpy.typing import NDArray
from clickstream_generator import catalog, plan, reference, world
from clickstream_generator import catalog, commerce, ids, plan, reference, world
from clickstream_generator.reference import Page
from clickstream_generator.seeds import Component, day_stream
from clickstream_generator.weights import pick, pick_row
DAY_SECONDS = 24 * 60 * 60
# Потолок идентификаторов тот же, что у кук: выше 2^53 числа в JSON
# округляются (контракт схемы, `WatchID`).
ID_LIMIT = plan.CLIENT_ID_LIMIT
# Карточка перед корзиной обязательна: положить в корзину то, чего не
# открывал, посетитель не может.
MIN_PAGES_BEFORE_CART = 2
# На столько секунд визит длиннее своих страниц: торговое событие встаёт
# позже страницы, на которую село, и последним событием визита бывает
# покупка, а не просмотр подтверждения. Величина нужна здесь дважды — визит
# с обещанным заказом обязан уместиться в сутки вместе с хвостом, а соседние
# визиты куки разводятся дальше таймаута тоже от хвоста, а не от страницы.
TRADE_TAIL_SECONDS = world.TRADE_DELAY_SECONDS[1]
_VISIT_COUNT_CUMULATIVE = np.cumsum(world.VISITS_PER_ACTIVE_DAY_WEIGHTS)
_VISIT_PAGES_CUMULATIVE = np.cumsum(world.VISIT_PAGES_WEIGHTS)
_SOURCE_CUMULATIVE = np.cumsum([source.weight for source in reference.TRAFFIC_SOURCES])
@@ -72,7 +78,7 @@ _SOURCE_CUMULATIVE = np.cumsum([source.weight for source in reference.TRAFFIC_SO
@dataclass(frozen=True, slots=True)
class Day:
"""Поток событий одного дня: колонки выгрузки и шов для торговых событий.
"""Поток событий одного дня: колонки выгрузки и страницы за ними.
Строки упорядочены по времени так их и проиграет проигрыватель.
`columns` колонки контракта схемы по его порядку, все до одной;
@@ -124,9 +130,15 @@ def stream(seed: int, day: int) -> Day:
second = np.repeat(start, visits.pages) + elapsed
# Граница суток режет визит: хвост за полночью в этот день не попадает.
alive = second < DAY_SECONDS
visit_id = np.repeat(_unique_ids(rng, len(visits)), visits.pages)
watch_id = _unique_ids(rng, int(alive.sum()))
# Странице подтверждения нужно место и под её покупку: подтверждение без
# покупки было бы потерей на клиентской стороне, а она здесь честная —
# потери и дубли стенд заводит намеренно и позже (этапы 4 и 6). Поэтому
# полночь забирает подтверждение вместе с торговым хвостом или не
# забирает ни того, ни другого.
room = np.where(page == Page.CONFIRMATION, TRADE_TAIL_SECONDS, 0)
alive = second + room < DAY_SECONDS
visit_id = np.repeat(ids.unique(rng, len(visits)), visits.pages)
watch_id = ids.unique(rng, int(alive.sum()))
rest = _columns(rng, day, audience, visits, page, product, second)
columns = {
@@ -136,12 +148,16 @@ def stream(seed: int, day: int) -> Day:
}
order = np.lexsort((columns["WatchID"], columns["UTCEventTime"]))
return Day(
day=day,
columns={name: value[order] for name, value in columns.items()},
page=page[alive][order],
product=product[alive][order],
# Торговые события садятся на готовый трафиковый поток и отдают его
# целиком: в нём же они и упорядочиваются.
columns, page, product = commerce.weave(
seed,
day,
{name: value[order] for name, value in columns.items()},
page[alive][order],
product[alive][order],
)
return Day(day=day, columns=columns, page=page, product=product)
def _visits(rng: np.random.Generator, audience: plan.DayAudience) -> _Visits:
@@ -161,7 +177,7 @@ def _visits(rng: np.random.Generator, audience: plan.DayAudience) -> _Visits:
# Обещанный планом заказ достаётся первому визиту дня: слева от него
# соседей нет, поэтому двигать его внутри суток можно свободно.
ordering = audience.assigned_order[cookie] & (ordinal == 0)
stage = _funnel(rng, visits, ordering)
stage = _funnel(rng, audience.buyer[cookie], ordering)
# Воронка удлиняет визит, а не съедает его: до корзины надо ещё дойти.
browse = np.where(
stage > 0, np.maximum(length - stage, MIN_PAGES_BEFORE_CART), length
@@ -181,17 +197,32 @@ def _visits(rng: np.random.Generator, audience: plan.DayAudience) -> _Visits:
def _funnel(
rng: np.random.Generator, visits: int, ordering: NDArray[np.bool_]
rng: np.random.Generator,
buyer: NDArray[np.bool_],
ordering: NDArray[np.bool_],
) -> NDArray[np.int64]:
"""Докуда дошёл визит: 0 — до корзины не дошёл, 3 — до подтверждения."""
draw = rng.integers(0, 100, (3, visits))
cart = draw[0] < world.CART_PERCENT
checkout = cart & (draw[1] < world.CHECKOUT_OF_CART_PERCENT)
"""Докуда дошёл визит: 0 — до корзины не дошёл, 3 — до подтверждения.
Помеченный планом покупатель отличается на обоих шагах: и до корзины
доходит чаще, и бросает её реже. Склонность покупать свойство
человека, а не визита, поэтому одинаковый для всех бросок оставил бы
метку плана словом без следа в данных (спека генератора, раздел 9).
"""
draw = rng.integers(0, 100, (3, buyer.size))
cart = draw[0] < np.where(buyer, world.BUYER_CART_PERCENT, world.CART_PERCENT)
checkout = cart & (
draw[1]
< np.where(
buyer,
world.BUYER_CHECKOUT_OF_CART_PERCENT,
world.CHECKOUT_OF_CART_PERCENT,
)
)
confirmation = checkout & (draw[2] < world.CONFIRMATION_OF_CHECKOUT_PERCENT)
stage = cart.astype(np.int64) + checkout + confirmation
# Заказ, обещанный планом, воронку проходит целиком: гарантия пар стоит
# на том, что событие покупки в этот день случится (#40 его и повесит).
# на том, что событие покупки в этот день случится: его повесит `commerce`.
stage[ordering] = len(reference.FUNNEL_PAGES)
return stage
@@ -219,7 +250,7 @@ def _walk(
page[row] = table[step[row]]
stage = int(visits.stage[visit])
if stage:
# В корзину — только с карточки: иначе #40 положит туда товар,
# В корзину — только с карточки: иначе в ней окажется товар,
# которого посетитель не открывал.
page[begin + browsed - 1] = Page.PRODUCT
page[begin + browsed : begin + browsed + stage] = funnel[:stage]
@@ -266,19 +297,28 @@ def _starts(
"""
hour = pick_row(rng, _hour_cumulative(day), audience.city[visits.cookie])
start = hour * 3600 + rng.integers(0, 3600, hour.size)
# Визит с обещанным заказом обязан уместиться в сутки целиком.
fits = np.minimum(start, DAY_SECONDS - duration - 1)
# Визит с обещанным заказом обязан уместиться в сутки целиком — вместе с
# торговым хвостом: событие покупки встаёт на секунды позже страницы
# подтверждения, и зажимать его к последней секунде значило бы ломать
# правило ровно там, ради чего оно написано.
fits = np.minimum(start, DAY_SECONDS - duration - TRADE_TAIL_SECONDS - 1)
start = np.where(visits.ordering, fits, start)
# Визиты куки идут по возрастанию времени и разведены дальше таймаута —
# иначе лаба склеила бы два визита в один и разошлась бы с `VisitID`.
# Разводятся они от последнего события визита, а им бывает покупка:
# считать от последней страницы значило бы отдать таймауту торговый хвост.
start = start[np.lexsort((start, visits.cookie))]
for repeat in range(1, len(world.VISITS_PER_ACTIVE_DAY_WEIGHTS)):
later = np.flatnonzero(visits.ordinal == repeat)
earlier = later - 1
start[later] = np.maximum(
start[later],
start[earlier] + duration[earlier] + world.VISIT_TIMEOUT_SECONDS + 1,
start[earlier]
+ duration[earlier]
+ TRADE_TAIL_SECONDS
+ world.VISIT_TIMEOUT_SECONDS
+ 1,
)
return start
@@ -370,7 +410,8 @@ def _columns(
"RegionCity": by_city("name"),
"RegionCountryID": np.full(total, reference.COUNTRY_REGION_ID, dtype=np.uint32),
"RegionCityID": by_city("region_id", np.uint32),
# Цели дублируют торговые события, поэтому их ставит #40.
# Цели дублируют торговые события, поэтому их ставит `commerce`:
# у просмотра страницы достигнутых целей нет.
"GoalsReached": _blank(total, "uint32"),
# Своих параметров сайт стенда пока не шлёт: вариант A/B-теста был бы
# постоянной куки, а не поведением дня. Решение отложено, не забыто:
@@ -449,21 +490,6 @@ def _ip_addresses(
)
def _unique_ids(rng: np.random.Generator, size: int) -> NDArray[np.uint64]:
"""Неповторяющиеся id ниже 2^53, разбросанные по диапазону.
Уникальность обещана не для красоты: `WatchID` ключ дедупликации при
переигровке дня (спека генератора, раздел 4), и два одинаковых id
склеили бы разные события. Поэтому не броски наугад, а шаги случайной
длины они не повторяются по построению, и потом перемешивание, чтобы
номер не выдавал порядок строк. Средний шаг весь диапазон, делённый на
число событий, поэтому в среднем ряд занимает его половину.
"""
step = ID_LIMIT // (size + 1)
ids = np.cumsum(rng.integers(1, step + 1, size, dtype=np.uint64))
return ids[np.argsort(rng.integers(0, size * size + 1, size), kind="stable")]
def _hour_cumulative(day: int) -> NDArray[np.int64]:
"""Веса часов суток по городам, накопленные: строка города — его волна."""
# Суббота и воскресенье — последние два дня недели, а D0 — понедельник.
@@ -0,0 +1,47 @@
"""Идентификаторы событий: неповторяющиеся числа, которые переживут JSON.
Уникальность обещана не для красоты: `WatchID` ключ дедупликации при
переигровке дня (спека генератора, раздел 4), и два одинаковых номера
склеили бы разные события. Строк дня две породы трафиковые и торговые,
и рисует их разная случайность, поэтому обещание держится здесь, в одном
месте на весь генератор: у приёма одно определение, иначе дисциплина живёт
копиями и расходится с ними.
"""
import numpy as np
from numpy.typing import NDArray
# Потолок идентификаторов: выше 2^53 числа в JSON (jq, консоль браузера)
# округляются при разборе, и id перестаёт быть собой. Настоящая Метрика так
# не делает — её id длиннее (мастер-спека, раздел 1.1).
LIMIT = 2**53
def unique(rng: np.random.Generator, size: int) -> NDArray[np.uint64]:
"""Неповторяющиеся id ниже 2^53, разбросанные по диапазону.
Не броски наугад, а шаги случайной длины они не повторяются по
построению, и потом перемешивание, чтобы номер не выдавал порядок
строк. Средний шаг весь диапазон, делённый на число событий, поэтому в
среднем ряд занимает его половину.
"""
step = LIMIT // (size + 1)
numbers = np.cumsum(rng.integers(1, step + 1, size, dtype=np.uint64))
return numbers[np.argsort(rng.integers(0, size * size + 1, size), kind="stable")]
def unique_apart_from(
rng: np.random.Generator, size: int, taken: NDArray[np.uint64]
) -> NDArray[np.uint64]:
"""То же, но и с занятыми номерами ряд не пересекается.
Торговые события берут случайность из своего подпотока иначе правка
торгового поведения сдвинула бы трафик, а значит, про уже розданные
трафиковые номера их ряд ничего не знает. Совпадение двух рядов на
диапазоне в 2^53 невероятно, но обещание уникальности держит дедуп, и
проверить его дешевле, чем предположить.
"""
while True:
numbers = unique(rng, size)
if not np.intersect1d(numbers, taken).size:
return numbers
+33 -14
View File
@@ -9,7 +9,9 @@
Что план решает до генерации событий и чем связывает дни между собой:
- приток кто и когда впервые появился, и сколько раз вернётся;
- приток кто и когда впервые появился, и сколько раз вернётся; кука
помеченного покупателя живёт дольше прочих это один из двух рычагов
метки, второй лежит в воронке дня;
- двухкуковые пары какой человек завёл вторую куку и в какие дни
каждая из двух кук обязана оформить заказ;
- паспорт куки устройство и город: они у куки одни и те же во всех её
@@ -32,17 +34,14 @@ from functools import lru_cache
import numpy as np
from numpy.typing import NDArray
from clickstream_generator import reference, world
from clickstream_generator import ids, reference, world
from clickstream_generator.seeds import cohort_stream
from clickstream_generator.weights import pick
# Куки живут числами ниже 2^53: выше JSON округляет — тот же довод, что у
# `WatchID` в контракте схемы. Граница не достигается: 2^53 сам уже за ней.
CLIENT_ID_LIMIT = 2**53
# Кумулятивные веса: выбор по ним — целочисленный, бросок попадает в чью-то
# долю общего веса.
_RETURN_COUNT_CUMULATIVE = np.cumsum(world.RETURN_COUNT_WEIGHTS)
_BUYER_RETURN_COUNT_CUMULATIVE = np.cumsum(world.BUYER_RETURN_COUNT_WEIGHTS)
_RETURN_DELAY_CUMULATIVE = np.cumsum(world.RETURN_DELAY_WEIGHTS)
_CITY_CUMULATIVE = np.cumsum([city.weight for city in reference.CITIES])
_DEVICE_CUMULATIVE = np.cumsum(
@@ -182,15 +181,19 @@ def cohort(seed: int, day: int) -> Cohort:
]
cookies = people + paired.size
client_id = rng.integers(1, CLIENT_ID_LIMIT, cookies, dtype=np.uint64)
# Кука живёт числом ниже 2^53 — тот же потолок, что у номера события:
# выше JSON округляет при разборе. Граница не достигается.
client_id = rng.integers(1, ids.LIMIT, cookies, dtype=np.uint64)
birth_day = np.full(cookies, day, dtype=np.int64)
# Вторая кука рождается, пока человек ещё ходит: тем же затухающим
# профилем, что и возвраты, — обычно через дни, изредка через месяцы.
# Фиксированного зазора нет, иначе пары в данных узнавались бы по нему.
birth_day[people:] += 1 + pick(rng, _RETURN_DELAY_CUMULATIVE, paired.size)
# Вторая кука принадлежит покупателю — как и первая кука его пары.
buyer_cookie = np.concatenate((buyer, np.ones(paired.size, dtype=bool)))
active_cookie, active_day = _active_days(
rng, birth_day, day + world.RETURN_TAIL_DAYS
rng, birth_day, buyer_cookie, day + world.RETURN_TAIL_DAYS
)
twins = np.column_stack((paired, np.arange(people, cookies, dtype=np.int64)))
pair_cookies, pair_order_days = _assign_orders(
@@ -204,8 +207,7 @@ def cohort(seed: int, day: int) -> Cohort:
people=people,
client_id=client_id,
birth_day=birth_day,
# Вторая кука принадлежит покупателю — как и первая кука его пары.
buyer=np.concatenate((buyer, np.ones(paired.size, dtype=bool))),
buyer=buyer_cookie,
active_cookie=active_cookie,
active_day=active_day,
pair_cookies=pair_cookies,
@@ -271,13 +273,30 @@ def _influx(rng: np.random.Generator, day: int) -> int:
def _active_days(
rng: np.random.Generator, birth_day: NDArray[np.int64], window_end: int
rng: np.random.Generator,
birth_day: NDArray[np.int64],
buyer: NDArray[np.bool_],
window_end: int,
) -> tuple[NDArray[np.int64], NDArray[np.int64]]:
"""Дни активности каждой куки: день рождения и возвраты, пока окно открыто."""
"""Дни активности каждой куки: день рождения и возвраты, пока окно открыто.
Помеченный планом покупатель живёт дольше прочих: одноразовым бывает
много реже и возвращается чаще. Это первый из двух рычагов метки
(второй воронка дня): метки в событии нет, поэтому «постоянный
покупатель» читается в данных только как кука, которая ходит неделями и
покупает не раз. Одним лифтом конверсии этого не добиться кука живёт
меньше двух визитов за снимок, и второй покупке негде случиться (спека
генератора, раздел 9).
"""
cookies = birth_day.size
returns = np.zeros(cookies, dtype=np.int64)
returning = rng.integers(0, 100, cookies) >= world.ONE_SHOT_PERCENT
returns[returning] = 1 + pick(rng, _RETURN_COUNT_CUMULATIVE, int(returning.sum()))
one_shot = np.where(buyer, world.BUYER_ONE_SHOT_PERCENT, world.ONE_SHOT_PERCENT)
returning = rng.integers(0, 100, cookies) >= one_shot
for here, weights in (
(returning & ~buyer, _RETURN_COUNT_CUMULATIVE),
(returning & buyer, _BUYER_RETURN_COUNT_CUMULATIVE),
):
returns[here] = 1 + pick(rng, weights, int(here.sum()))
owner = np.repeat(np.arange(cookies, dtype=np.int64), returns)
delay = 1 + pick(rng, _RETURN_DELAY_CUMULATIVE, owner.size)
@@ -77,6 +77,19 @@ SEARCH_QUERIES = tuple(
)
# Вариант товара — цвет или исполнение карточки. Живёт только в сыром
# `ecommerce`: в плоских массивах события такой колонки нет, и это одна из
# причин, по которым лаба «сырое против разобранного» вообще имеет смысл.
PRODUCT_VARIANTS = (
"стандарт",
"белый",
"серый",
"бежевый",
"синий",
"зелёный",
)
# Коды типа устройства у Метрики: 1 — десктоп, 2 — телефон, 3 — планшет,
# 4 — телевизор. Телефон назван отдельно: по нему различаются и мобильный
# адрес, и вторая кука пары — «телефон и ноутбук» (мастер-спека, раздел 5).
@@ -21,8 +21,10 @@ PREAMBLE = """# Описание выгрузки: событие кликстр
пересобрать: `make docs`.
Одно событие одна строка: хит по образцу облачной выгрузки Яндекс Метрики.
Многозначное лежит в параллельных массивах одной длины, плюс одно сырое
JSON-поле `ecommerce`. Отдельной сущности «визит» в выгрузке нет визиты
Многозначное лежит в параллельных массивах, плюс одно сырое JSON-поле
`ecommerce`. Длина у массивов общая **внутри группы**, а не по всему
событию: `purchase*` по элементу на заказ (у нас всегда один), `product*`
по элементу на товар. Отдельной сущности «визит» в выгрузке нет визиты
собирают на стороне хранилища, а `VisitID` дан как эталон для самопроверки.
Имена и типы колонок стороны источника. Хранилище принимает их как есть и
@@ -37,6 +39,10 @@ JSON-поле `ecommerce`. Отдельной сущности «визит» в
`add_to_cart` несёт один товар, `purchase` состав заказа и блок
`purchase*`. У остальных событий они пусты.
Деньги: `productPrice` целые рубли, округление формата. Точная сумма
заказа живёт в `purchaseRevenue` и в сыром `ecommerce`, поэтому пересчитать
выручку по разобранным массивам нельзя цены каталога бывают с копейками.
Всего колонок: {count}."""
TABLE_HEADER = (
+62 -3
View File
@@ -82,6 +82,15 @@ BUYER_PERCENT = 5
# этом стоит лаба про склейку личности.
PAIRED_BUYER_PERCENT = 15
# Первый рычаг метки покупателя: помеченный дольше живёт и чаще возвращается.
# Одноразовым он бывает много реже прочих, а возвращается вдвое чаще —
# отсюда кука, которая ходит неделями. Без этого рычага «постоянный
# покупатель» в данных не читается: метки в событии нет и не будет
# (кликстрим анонимен), а кука живёт меньше двух визитов за снимок — второй
# покупке негде случиться (спека генератора, раздел 9).
BUYER_ONE_SHOT_PERCENT = 35
BUYER_RETURN_COUNT_WEIGHTS = (10, 11, 12, 12, 11, 10, 9, 7, 5, 4, 3, 2)
# --- Числа дня: суточная волна, визиты, воронка ---------------------------
# Суточная волна буднего дня: проценты от среднего часа, от 00 до 23 часов
@@ -111,7 +120,7 @@ VISITS_PER_ACTIVE_DAY_WEIGHTS = (70, 22, 8)
# Длина визита в страницах: веса для 1, 2, 3 … страниц. Первая доля — отказы
# (посмотрел одну страницу и ушёл), дальше затухающий хвост. В среднем ≈4,8
# страницы: вместе с числом визитов это ~45 тыс. pageview в средний день,
# и до ~50 тыс. добирают торговые события (#40).
# и до ~50 тыс. добирают торговые события.
VISIT_PAGES_WEIGHTS = (250, 150, 120, 100, 88, 78, 68, 58, 50, 42, 35, 28, 22, 16)
# Таймаут визита: пауза дольше этой рвёт визит надвое. Правило резки, по
@@ -128,9 +137,59 @@ LONG_PAUSE_SECONDS = (300, 1500)
LONG_PAUSE_PERCENT = 12
# Воронка: доля визитов, дошедших до корзины, и доли следующих шагов от
# предыдущего. Произведение — конверсия визита в оформленный заказ: 2%
# предыдущего. Произведение — конверсия визита в оформленный заказ
# (спека генератора, раздел 9). Гарантированные планом заказы двухкуковых
# пар проходят воронку целиком независимо от этих долей.
CART_PERCENT = 8
CART_PERCENT = 6
CHECKOUT_OF_CART_PERCENT = 45
CONFIRMATION_OF_CHECKOUT_PERCENT = 55
# Второй рычаг метки покупателя: помеченный отличается на обоих шагах
# воронки — и до корзины доходит чаще, и бросает её реже. В жизни
# различаются оба: кто пришёл смотреть, тот и кладёт реже, и до конца
# доводит реже; один шаг дал бы половину картины. Шаг подтверждения общий:
# оплата — про магазин, а не про склонность покупать.
BUYER_CART_PERCENT = 18
BUYER_CHECKOUT_OF_CART_PERCENT = 60
# --- Числа торговых событий: корзина, заказ, деньги ------------------------
# Торговое событие встаёт на несколько секунд позже своей страницы: корзина
# позже карточки, покупка позже подтверждения. Задержка короче самой
# короткой паузы между страницами — иначе событие корзины обогнало бы
# страницу, на которой посетитель его нажал.
TRADE_DELAY_SECONDS = (2, 8)
# Доля позиций корзины, которые остались брошенными: заказ уже корзины.
# Иначе событие корзины не рассказывало бы ничего сверх покупки — заказ был
# бы её точной копией, и сравнивать было бы нечего. Пустым заказ не бывает:
# если брошены все позиции, одна остаётся (спека генератора, раздел 9).
ABANDONED_POSITION_PERCENT = 15
# Сколько штук одного товара берут: веса для 1, 2, 3 штук. Обычно одна,
# изредка две-три — непродовольственная розница.
ITEM_QUANTITY_WEIGHTS = (85, 11, 4)
# Валюта магазина: один регион присутствия — одна валюта.
CURRENCY = "RUB"
# Доля заказов с промокодом.
COUPON_PERCENT = 20
# Промокоды: код и скидка в процентах. Таблица — число мира, а не выдумка
# бэкенда: этап 3 берёт её готовой и обязан дать заказу с кодом скидку,
# иначе данные соврут. В клиентскую выручку скидка не входит — код на сайте
# знает корзину, а не итог расчёта (спека генератора, раздел 9). Цифры в
# коде — те же проценты: скидка в рублях потребовала бы второго правила
# чтения таблицы, а вместе с ним и второго вида скидки на стороне бэкенда.
COUPONS = (
("VESNA10", 10),
("DOMASHNIY5", 5),
("PERVYY15", 15),
("UYUT7", 7),
)
# Цели счётчика: корзина и покупка. Цели дублируют торговые события — в бою
# так и бывает (мастер-спека, раздел 1.2).
GOAL_CART_ID = 42150001
GOAL_PURCHASE_ID = 42150002
+12
View File
@@ -54,6 +54,18 @@ def test_every_row_is_filled_and_priced_in_whole_kopecks():
assert low <= int(row["price"]) <= high, row["sku"]
def test_part_of_the_prices_carry_kopecks():
"""Без копеек урок про Float64 беспредметен: округлять было бы нечего.
Сторожится не доля, а то, на чём стоит урок: цены бывают и кратные
рублю, и с копейками тогда `productPrice` округляется форматом, а
`purchaseRevenue` несёт точную сумму.
"""
kopecks = [int(row["price"]) % 100 for row in rows()]
assert any(rest for rest in kopecks)
assert any(not rest for rest in kopecks)
def test_every_category_of_the_assortment_is_covered():
"""Каталог покрывает ассортимент целиком: пустых категорий не бывает."""
present = {row["category"] for row in rows()}
+488
View File
@@ -0,0 +1,488 @@
"""Торговые события: корзина шире заказа, деньги целые, номера читаемые.
Числа мира тесты сторожат вилками спеки, а не точными значениями: менти
крутит конфигурацию, и падать тесты должны там, где сдвинулся вывод («заказ
уже корзины», «конверсия визита около 2%»), а не при каждой правке.
Неделя дня-функции стоит секунд, поэтому дни, которые нужны нескольким
тестам, считаются один раз на модуль.
"""
import json
from typing import Any
import numpy as np
import pytest
from numpy.typing import NDArray
from clickstream_generator import catalog, commerce, day, plan, schema, world
from clickstream_generator.reference import Page
from clickstream_generator.seeds import CANONICAL_SEED, Component
WEEKDAY = 2
WEEK = 7
@pytest.fixture(autouse=True)
def fresh_memo():
"""Когорты запоминаются; тесты сравнивают вычисления, а не ссылки."""
plan.cohort.cache_clear()
@pytest.fixture(scope="module")
def weekday() -> day.Day:
return day.stream(CANONICAL_SEED, WEEKDAY)
@pytest.fixture(scope="module")
def week() -> list[day.Day]:
"""Неделя мира: повторные покупки и обещания пар живут между днями."""
return [day.stream(CANONICAL_SEED, number) for number in range(WEEK)]
def rows_of(events: day.Day, kind: str) -> dict[str, NDArray[Any]]:
"""Строки одного типа события — все колонки разом."""
here = events.columns["EventType"] == kind
return {name: value[here] for name, value in events.columns.items()}
def cells(column: NDArray[Any]) -> list[list[Any]]:
return [cell.tolist() for cell in column]
def raw_of(rows: dict[str, NDArray[Any]]) -> list[dict[str, Any]]:
"""Сырой `ecommerce` разобранный — чужим разбором, не своим сборщиком."""
return [json.loads(text) for text in rows["ecommerce"]]
def by_visit(events: day.Day) -> NDArray[np.int64]:
"""Порядок строк «визит, потом время» — так их читает лаба сессий."""
return np.lexsort((events.columns["UTCEventTime"], events.columns["VisitID"]))
def test_the_day_carries_both_trade_events(weekday: day.Day):
"""Таксономия ожила: в дне есть и корзина, и покупка."""
carts = rows_of(weekday, commerce.ADD_TO_CART)
orders = rows_of(weekday, commerce.PURCHASE)
assert orders["WatchID"].size > 100
# Корзина шире заказа и по событиям: не всякая корзина доходит до кассы.
assert carts["WatchID"].size > 2 * orders["WatchID"].size
def confirmed_visits(events: day.Day) -> set[int]:
"""Визиты, чья страница подтверждения дожила до потока."""
pageview = events.columns["EventType"] == "pageview"
here = pageview & (events.page == Page.CONFIRMATION)
return set(events.columns["VisitID"][here].tolist())
def test_a_confirmation_in_the_stream_always_has_its_purchase(weekday: day.Day):
"""Клиентская сторона честная: подтверждение без покупки — это потеря.
Потери и дубли стенд заводит намеренно и позже (этапы 4 и 6); здесь их
быть не должно ни одной. Обратное тоже верно: покупка без подтверждения
в потоке заказ ниоткуда.
"""
bought = set(rows_of(weekday, commerce.PURCHASE)["VisitID"].tolist())
assert confirmed_visits(weekday) == bought
def test_the_day_boundary_takes_the_confirmation_together_with_its_purchase(
monkeypatch: pytest.MonkeyPatch,
):
"""Полночь режет подтверждение и покупку вместе — или не режет ни одну.
Иначе визит отдал бы страницу «Заказ оформлен» без события покупки, и на
клиентской стороне завелась бы потеря. Хвост торгового события здесь
растянут до часа: в каноническом мире он секунды, и такой визит
выпадает раз в годы, а правило обязано держаться при любом хвосте.
"""
monkeypatch.setattr(world, "TRADE_DELAY_SECONDS", (3_000, 3_600))
monkeypatch.setattr(day, "TRADE_TAIL_SECONDS", 3_600)
events = day.stream(CANONICAL_SEED, WEEKDAY)
bought = set(rows_of(events, commerce.PURCHASE)["VisitID"].tolist())
assert bought
assert confirmed_visits(events) == bought
def test_a_purchase_carries_exactly_one_order_number(weekday: day.Day):
"""Одно подтверждение — один заказ (мастер-спека, раздел 4)."""
orders = rows_of(weekday, commerce.PURCHASE)
for name in ("purchaseID", "purchaseRevenue", "purchaseCurrency", "purchaseCoupon"):
assert {cell.size for cell in orders[name]} == {1}, name
assert {cell[0] for cell in orders["purchaseCurrency"]} == {world.CURRENCY}
def test_the_cart_event_has_no_order_block(weekday: day.Day):
"""У корзины заказа ещё нет: блок `purchase*` пуст, как у просмотра."""
carts = rows_of(weekday, commerce.ADD_TO_CART)
for name in ("purchaseID", "purchaseRevenue", "purchaseCurrency", "purchaseCoupon"):
assert {cell.size for cell in carts[name]} == {0}, name
def test_the_product_arrays_of_an_event_share_one_length(weekday: day.Day):
"""Длина общая внутри группы `product*`; с `purchase*` она не совпадает."""
trade = weekday.columns["EventType"] != "pageview"
names = [
column.name for column in schema.COLUMNS if column.name.startswith("product")
]
assert len(names) == 6
sizes = np.array(
[[cell.size for cell in weekday.columns[name][trade]] for name in names]
)
assert np.all(sizes == sizes[0])
# Корзина — всегда один товар, заказ — от одного и больше.
carts = rows_of(weekday, commerce.ADD_TO_CART)
assert {cell.size for cell in carts["productID"]} == {1}
orders = rows_of(weekday, commerce.PURCHASE)
assert min(cell.size for cell in orders["productID"]) == 1
assert max(cell.size for cell in orders["productID"]) > 1
def test_the_raw_json_agrees_with_the_flat_arrays(weekday: day.Day):
"""Сырое и разобранное — одно событие, поле в поле.
Сверяется всё, что есть по обе стороны: разъедься хоть одно поле, лаба
«сырое против разобранного» учила бы неправде. Цена единственная пара,
где расхождение законно: в колонке целые рубли, в сыром точная сумма,
поэтому сверяется не равенство, а что колонка та же цена, округлённая
до рубля.
"""
for kind, action in (
(commerce.ADD_TO_CART, commerce.ADD_ACTION),
(commerce.PURCHASE, commerce.PURCHASE_ACTION),
):
rows = rows_of(weekday, kind)
assert rows["ecommerce"].size > 100
for number, block in enumerate(raw_of(rows)):
assert block["currencyCode"] == world.CURRENCY
products = block[action]["products"]
for field, column in (
("id", "productID"),
("name", "productName"),
("category", "productCategory"),
("quantity", "productQuantity"),
):
assert [item[field] for item in products] == (
rows[column][number].tolist()
), (kind, field)
assert rows["productEventType"][number].tolist() == [action] * len(products)
prices = zip(
(item["price"] for item in products),
rows["productPrice"][number].tolist(),
strict=True,
)
assert all(abs(exact - whole) <= 0.5 for exact, whole in prices)
orders = rows_of(weekday, commerce.PURCHASE)
for number, block in enumerate(raw_of(orders)):
field = block[commerce.PURCHASE_ACTION]["actionField"]
assert field["id"] == orders["purchaseID"][number][0]
assert field["revenue"] == orders["purchaseRevenue"][number][0]
def test_the_raw_json_carries_what_the_arrays_do_not(weekday: day.Day):
"""Иначе лаба «сырое против разобранного» разбирала бы то же самое."""
orders = rows_of(weekday, commerce.PURCHASE)
goods = catalog.catalog()
brands = set(goods.brand.tolist())
seen_brands: set[str] = set()
for block in raw_of(orders):
for item in block[commerce.PURCHASE_ACTION]["products"]:
assert item["brand"] in brands
assert item["variant"]
seen_brands.add(item["brand"])
assert len(seen_brands) > 1
assert not any(
column.name in ("productBrand", "productVariant") for column in schema.COLUMNS
)
def test_money_is_whole_kopecks_inside_and_float_only_on_the_surface(weekday: day.Day):
"""Деньги целые; дробное число в событии одно — выручка (спека, раздел 2)."""
floats = [
column.name for column in schema.COLUMNS if column.numpy_dtype == "float64"
]
assert floats == ["purchaseRevenue"]
goods = catalog.catalog()
orders = rows_of(weekday, commerce.PURCHASE)
sku = {article: number for number, article in enumerate(goods.sku.tolist())}
for number, revenue in enumerate(orders["purchaseRevenue"]):
assert revenue.dtype == np.float64
kopecks = sum(
int(goods.price[sku[article]]) * pieces
for article, pieces in zip(
orders["productID"][number].tolist(),
orders["productQuantity"][number].tolist(),
strict=True,
)
)
assert revenue[0] == kopecks / 100
assert orders["productPrice"][number].dtype == np.int64
def test_the_rounded_price_does_not_add_up_to_the_revenue(weekday: day.Day):
"""Часть цен несёт копейки, и выручку по массивам события не пересобрать.
Разрыв живёт внутри одного события: `productPrice` округлён форматом,
`purchaseRevenue` точна. Это и есть урок про Float64 не выдуманный.
"""
orders = rows_of(weekday, commerce.PURCHASE)
apart = 0
for number, revenue in enumerate(orders["purchaseRevenue"]):
by_arrays = float(
(orders["productPrice"][number] * orders["productQuantity"][number]).sum()
)
apart += by_arrays != revenue[0]
assert 0.1 < apart / orders["WatchID"].size < 0.9
def test_the_order_is_narrower_than_the_cart(weekday: day.Day):
"""Часть положенных позиций не куплена — иначе сравнивать было бы нечего."""
carts = rows_of(weekday, commerce.ADD_TO_CART)
orders = rows_of(weekday, commerce.PURCHASE)
put: dict[int, set[str]] = {}
for visit, article in zip(
carts["VisitID"].tolist(), (cell[0] for cell in carts["productID"]), strict=True
):
put.setdefault(visit, set()).add(article)
dropped = whole = 0
for visit, bought in zip(
orders["VisitID"].tolist(), cells(orders["productID"]), strict=True
):
assert bought, "пустых заказов не бывает"
assert set(bought) <= put[visit], "куплено то, чего не клали в корзину"
assert len(set(bought)) == len(bought), "позиция в заказе одна на товар"
dropped += len(put[visit]) - len(bought)
whole += len(put[visit])
assert 0.05 < dropped / whole < 0.30
def test_the_cart_holds_only_what_the_visitor_opened(weekday: day.Day):
"""Товар в корзине, которого никто не открывал, — глупость в воронке."""
order = by_visit(weekday)
visit = weekday.columns["VisitID"][order]
kind = weekday.columns["EventType"][order]
product = weekday.product[order]
page = weekday.page[order]
shown: dict[int, set[int]] = {}
for number, this in enumerate(visit.tolist()):
if kind[number] == "pageview" and page[number] == Page.PRODUCT:
shown.setdefault(this, set()).add(int(product[number]))
put: dict[int, list[int]] = {}
for number, this in enumerate(visit.tolist()):
if kind[number] == commerce.ADD_TO_CART:
put.setdefault(this, []).append(int(product[number]))
assert put
for this, products in put.items():
assert set(products) <= shown[this]
# Карточка, открытая дважды, даёт одну позицию, а не две.
assert len(set(products)) == len(products)
def test_a_trade_event_sits_on_the_page_that_sent_it(weekday: day.Day):
"""Корзина — на карточке того самого товара, покупка — на подтверждении.
Событие не просто стоит после какой-то страницы: у события корзины
предыдущая строка визита карточка именно этого товара, и товар в
массивах события тот же. Иначе в корзину попал бы товар, которого
посетитель не открывал, а событие село бы на чужую страницу.
"""
order = by_visit(weekday)
columns = {name: value[order] for name, value in weekday.columns.items()}
page, product = weekday.page[order], weekday.product[order]
kind = columns["EventType"]
goods = catalog.catalog()
trade = np.flatnonzero(kind != "pageview")
assert trade.size > 100
for row in trade.tolist():
assert columns["VisitID"][row] == columns["VisitID"][row - 1]
assert columns["URL"][row] == columns["URL"][row - 1]
assert columns["Referer"][row] == columns["Referer"][row - 1]
away = (columns["UTCEventTime"][row] - columns["UTCEventTime"][row - 1]).astype(
"int64"
)
assert 0 < away < world.TRADE_DELAY_SECONDS[1]
if kind[row] == commerce.ADD_TO_CART:
assert page[row] == page[row - 1] == Page.PRODUCT
# Карточка предыдущей строки — карточка этого самого товара.
assert product[row] == product[row - 1] >= 0
assert columns["productID"][row].tolist() == [goods.sku[product[row]]]
# У страницы входа в адресе ещё метки перехода — путь до «?».
address = columns["URL"][row].split("?")[0]
assert address.endswith(f"/product/{goods.sku[product[row]]}")
else:
assert page[row] == page[row - 1] == Page.CONFIRMATION
assert kind[row - 1] == "pageview"
def test_the_goals_repeat_the_trade_events(weekday: day.Day):
"""Цели дублируют события — в бою так и бывает (мастер-спека, 1.2)."""
goals = {
kind: {tuple(cell.tolist()) for cell in rows_of(weekday, kind)["GoalsReached"]}
for kind in ("pageview", commerce.ADD_TO_CART, commerce.PURCHASE)
}
assert goals["pageview"] == {()}
assert goals[commerce.ADD_TO_CART] == {(world.GOAL_CART_ID,)}
assert goals[commerce.PURCHASE] == {(world.GOAL_PURCHASE_ID,)}
def test_the_order_number_reads_as_the_day_and_the_count(weekday: day.Day):
"""Номер — день и порядковый номер покупки в нём; он же `order_id` бэкенда."""
orders = rows_of(weekday, commerce.PURCHASE)
numbers = [cell[0] for cell in orders["purchaseID"]]
date = str(weekday.columns["EventDate"][0]).replace("-", "")
assert numbers == [f"{date}-{place:04d}" for place in range(1, len(numbers) + 1)]
assert len(set(numbers)) == len(numbers)
# Нумерация идёт в порядке событий: строки дня уже упорядочены по времени.
assert list(orders["UTCEventTime"]) == sorted(orders["UTCEventTime"])
def test_the_coupon_comes_from_the_world_table_and_does_not_touch_the_revenue(
weekday: day.Day,
):
"""Промокод в событии есть, скидки в сумме нет: клиент шлёт сумму позиций."""
orders = rows_of(weekday, commerce.PURCHASE)
known = {code for code, _ in world.COUPONS}
coupons = [cell[0] for cell in orders["purchaseCoupon"]]
assert set(coupons) <= known | {""}
with_coupon = [code for code in coupons if code]
assert 0.05 < len(with_coupon) / len(coupons) < 0.40
assert len(set(with_coupon)) > 1
# Скидка в клиентскую выручку не входит: сумма сходится с позициями и с
# тем, что уехало в сыром JSON.
for number, block in enumerate(raw_of(orders)):
action = block[commerce.PURCHASE_ACTION]["actionField"]
assert action["revenue"] == orders["purchaseRevenue"][number][0]
assert ("coupon" in action) == bool(coupons[number])
def test_editing_the_trade_flow_does_not_move_the_traffic(
monkeypatch: pytest.MonkeyPatch,
):
"""Иерархия подпотоков: у торговли своя случайность (спека, раздел 2)."""
ours = day.stream(CANONICAL_SEED, WEEKDAY)
plan.cohort.cache_clear()
monkeypatch.setattr(world, "ABANDONED_POSITION_PERCENT", 40)
other = day.stream(CANONICAL_SEED, WEEKDAY)
def pageviews(events: day.Day) -> dict[str, list[Any]]:
rows = rows_of(events, "pageview")
return {
name: [
cell.tolist() if isinstance(cell, np.ndarray) else cell
for cell in value
]
for name, value in rows.items()
}
assert pageviews(other) == pageviews(ours)
assert [
cell.tolist() for cell in rows_of(other, commerce.PURCHASE)["productID"]
] != [cell.tolist() for cell in rows_of(ours, commerce.PURCHASE)["productID"]]
def test_the_trade_flow_draws_from_its_own_named_branch(
monkeypatch: pytest.MonkeyPatch,
):
"""Торговля берёт подпоток `COMMERCE` — именно его, а не любой другой.
Неподвижности трафика для этого мало: возьми торговля ветвь трафика,
трафик всё равно не сдвинулся бы свои броски он сделал раньше и из
своего генератора. Разошлись бы только два подпотока, ставшие одним, а
в дереве зерна место каждого компонента и есть контракт (спека,
раздел 2). Поэтому проверяется адрес, по которому торговля пришла.
"""
asked: list[tuple[int, int, Component]] = []
honest = commerce.day_stream
def spy(seed: int, day_number: int, component: Component):
asked.append((seed, day_number, component))
return honest(seed, day_number, component)
monkeypatch.setattr(commerce, "day_stream", spy)
day.stream(CANONICAL_SEED, WEEKDAY)
assert asked == [(CANONICAL_SEED, WEEKDAY, Component.COMMERCE)]
def test_the_promised_orders_of_a_pair_become_purchases(week: list[day.Day]):
"""Гарантия плана: обе куки пары покупают в назначенные им дни.
Проверяются пары, у которых оба назначенных дня попали внутрь недели:
остальные ждут своего дня за горизонтом.
"""
bought = [
set(rows_of(events, commerce.PURCHASE)["ClientID"].tolist()) for events in week
]
pairs = 0
for born in range(-world.RETURN_TAIL_DAYS, WEEK):
cohort = plan.cohort(CANONICAL_SEED, born)
for cookies, days in zip(
cohort.pair_cookies.tolist(), cohort.pair_order_days.tolist(), strict=True
):
if max(days) >= WEEK:
continue
pairs += 1
for cookie, number in zip(cookies, days, strict=True):
assert int(cohort.client_id[cookie]) in bought[number]
assert pairs > 10
# Счётчик пар в плане и пары, реализованные в дне, — про одно и то же.
assert pairs == plan.counters(CANONICAL_SEED, WEEK).pairs
def paired_cookies() -> set[int]:
"""Куки двухкуковых пар: их заказы обещаны планом, а не решены днём."""
cookies: set[int] = set()
for born in range(-world.RETURN_TAIL_DAYS, WEEK):
cohort = plan.cohort(CANONICAL_SEED, born)
cookies |= set(cohort.client_id[cohort.pair_cookies.ravel()].tolist())
return cookies
def test_flagged_buyers_buy_again_much_more_often(week: list[day.Day]):
"""Метка покупателя перестала быть словом: у неё виден след в данных.
«Постоянный покупатель» читается только так как кука, которая ходит
неделями и покупает не раз: метки в событии нет и не будет.
Куки двухкуковых пар из счёта исключены: их заказы назначил план, и
вместе с ними лифт мерил бы гарантию, а не два новых рычага. Остаются
те помеченные, чьи покупки решил день: повторно покупают 4,6% из них
против 1,5% прочих втрое чаще при пороге «вдвое».
"""
purchases: dict[int, int] = {}
flagged: set[int] = set()
for number, events in enumerate(week):
audience = plan.audience(CANONICAL_SEED, number)
flagged |= set(audience.client_id[audience.buyer].tolist())
for cookie in rows_of(events, commerce.PURCHASE)["ClientID"].tolist():
purchases[cookie] = purchases.get(cookie, 0) + 1
def repeat_share(cookies: set[int]) -> float:
again = sum(1 for cookie in cookies if purchases[cookie] > 1)
return again / len(cookies)
buyers = set(purchases)
by_levers = (buyers & flagged) - paired_cookies()
others = buyers - flagged
assert len(by_levers) > 100
assert len(others) > 100
assert repeat_share(by_levers) > 2 * repeat_share(others)
def test_the_shop_stays_the_same_plausible_shop(week: list[day.Day]):
"""Границы правки — вилки спеки (раздел 5): аудитория, объём, конверсия."""
counters = plan.counters(CANONICAL_SEED, WEEK)
assert all(6_000 <= size <= 8_000 for size in counters.audience)
for events in week:
visits = len(set(events.columns["VisitID"].tolist()))
orders = int((events.columns["EventType"] == commerce.PURCHASE).sum())
assert 40_000 < len(events) < 60_000
assert 0.015 < orders / visits < 0.035
+45 -19
View File
@@ -1,4 +1,4 @@
"""День-функция: чистота, форма волны, правила резки визитов, шов для #40.
"""День-функция: чистота, форма волны, правила резки визитов, шов с торговлей.
Числа мира тесты сторожат вилками спеки, а не точными значениями: менти
крутит конфигурацию, и падать тесты должны там, где сдвинулся вывод («средний
@@ -14,7 +14,16 @@ import numpy as np
import pytest
from numpy.typing import NDArray
from clickstream_generator import catalog, day, plan, reference, schema, world
from clickstream_generator import (
catalog,
commerce,
day,
ids,
plan,
reference,
schema,
world,
)
from clickstream_generator.reference import Page
from clickstream_generator.seeds import CANONICAL_SEED
@@ -137,25 +146,36 @@ def test_every_column_of_the_contract_is_present_and_typed(weekday: day.Day):
assert values.dtype == np.dtype(column.numpy_dtype), column.name
def test_what_is_left_to_the_trade_ticket_is_empty_not_missing(weekday: day.Day):
def test_a_pageview_carries_the_trade_columns_empty_not_missing(weekday: day.Day):
"""Пусто — пустой массив и пустая строка, а ключ есть у каждого события.
Проверяются все колонки будущих торговых событий, а не выбранные: тикет
#40 добавит свои, и они должны попасть под тот же сторож.
Проверяются все торговые колонки, а не выбранные: у просмотра страницы
пусты они все до одной, иначе строгий приём хранилища не уживётся с
полями, пустыми по смыслу (мастер-спека, раздел 6).
"""
waiting = [
pageview = weekday.columns["EventType"] == "pageview"
assert pageview.mean() > 0.9
trade = [
column
for column in schema.COLUMNS
if column.group in (schema.ColumnGroup.ECOMMERCE, schema.ColumnGroup.PARAMS)
]
assert len(waiting) > 10
for column in waiting:
cells = weekday.columns[column.name][:1000]
assert len(trade) > 10
for column in trade:
cells = weekday.columns[column.name][pageview][:1000]
if column.clickhouse_type.startswith("Array("):
assert all(cell.size == 0 for cell in cells), column.name
else:
assert set(cells) == {""}, column.name
assert set(weekday.columns["EventType"].tolist()) == {"pageview"}
def test_the_taxonomy_is_three_event_types(weekday: day.Day):
"""`EventType` — добавка стенда: таксономия нужна явно (мастер-спека, 1.1)."""
assert set(weekday.columns["EventType"].tolist()) == {
"pageview",
commerce.ADD_TO_CART,
commerce.PURCHASE,
}
def test_no_column_hides_a_hole(weekday: day.Day):
@@ -171,7 +191,7 @@ def test_no_column_hides_a_hole(weekday: day.Day):
def test_identifiers_survive_json(weekday: day.Day):
"""Числа выше 2^53 в JSON округляются — идентификаторам столько не нужно."""
for name in ("WatchID", "VisitID", "ClientID"):
assert weekday.columns[name].max() < day.ID_LIMIT
assert weekday.columns[name].max() < ids.LIMIT
assert weekday.columns[name].dtype == np.uint64
@@ -229,7 +249,7 @@ def test_the_counter_timezone_moves_the_date_apart_from_utc(weekday: day.Day):
def test_the_scale_of_an_average_day_is_about_fifty_thousand(weekday: day.Day):
"""Порядок величины (спека, раздел 5); итог сложится после #40."""
"""Порядок величины: ~50 тыс. событий в средний день (спека, раздел 5)."""
assert 30_000 < len(weekday) < 70_000
visits = len(set(weekday.columns["VisitID"].tolist()))
assert 6_000 < visits < 14_000
@@ -256,7 +276,7 @@ def test_the_weekend_is_shaped_unlike_a_weekday(weekday: day.Day):
def test_the_funnel_converts_about_two_percent_of_visits(weekday: day.Day):
visits = len(set(weekday.columns["VisitID"].tolist()))
ordered = int((weekday.page == Page.CONFIRMATION).sum())
ordered = int((weekday.columns["EventType"] == commerce.PURCHASE).sum())
assert 0.01 < ordered / visits < 0.04
@@ -270,7 +290,7 @@ def test_the_promised_orders_reach_the_confirmation(weekday: day.Day):
def test_the_cart_always_follows_a_product_card(weekday: day.Day):
"""Шов для #40: товар в корзине посетитель до того открывал."""
"""Шов с торговлей: товар в корзине посетитель до того открывал."""
order = np.lexsort((local_seconds(weekday), weekday.columns["VisitID"]))
by_visit = weekday.page[order]
carts = np.flatnonzero(by_visit == Page.CART)
@@ -292,11 +312,17 @@ def test_the_product_of_a_card_is_the_seam_for_trade_events(weekday: day.Day):
def test_the_referer_is_the_page_before(weekday: day.Day):
"""Внутри визита реферер — предыдущий адрес; на входе — адрес источника."""
order = np.lexsort((local_seconds(weekday), weekday.columns["VisitID"]))
url = weekday.columns["URL"][order]
referer = weekday.columns["Referer"][order]
visit = weekday.columns["VisitID"][order]
"""Внутри визита реферер — предыдущий адрес; на входе — адрес источника.
Цепочка считается по просмотрам страниц: торговое событие не открывает
страницу, а живёт на уже открытой, и реферер у него тот же, что у неё.
"""
pageview = weekday.columns["EventType"] == "pageview"
columns = {name: value[pageview] for name, value in weekday.columns.items()}
order = np.lexsort((columns["UTCEventTime"], columns["VisitID"]))
url = columns["URL"][order]
referer = columns["Referer"][order]
visit = columns["VisitID"][order]
inside = np.flatnonzero(visit[1:] == visit[:-1]) + 1
assert np.all(referer[inside] == url[inside - 1])
+41
View File
@@ -0,0 +1,41 @@
"""Идентификаторы событий: неповторяющиеся и переживающие JSON.
Обещание одно, но держит его дедупликация при переигровке дня: два
одинаковых `WatchID` склеили бы разные события (спека, раздел 4).
"""
import numpy as np
from clickstream_generator import ids
from clickstream_generator.seeds import CANONICAL_SEED
def stream(offset: int = 0) -> np.random.Generator:
return np.random.Generator(np.random.PCG64(CANONICAL_SEED + offset))
def test_ids_do_not_repeat_and_survive_json():
numbers = ids.unique(stream(), 10_000)
assert numbers.dtype == np.uint64
assert len(set(numbers.tolist())) == numbers.size
assert numbers.max() < ids.LIMIT
assert numbers.min() > 0
def test_ids_do_not_give_away_the_order_of_the_rows():
"""Иначе номер события рассказывал бы, каким по счёту оно родилось."""
numbers = ids.unique(stream(), 1_000)
assert not np.all(np.diff(numbers.astype(np.int64)) > 0)
def test_ids_of_another_stream_are_other_ids():
assert not np.array_equal(ids.unique(stream(), 100), ids.unique(stream(1), 100))
def test_ids_can_be_drawn_beside_the_ones_already_taken():
"""Торговые строки берут номера из своего подпотока и не задевают чужие."""
taken = ids.unique(stream(), 5_000)
numbers = ids.unique_apart_from(stream(1), 500, taken)
assert len(set(numbers.tolist())) == numbers.size
assert not set(numbers.tolist()) & set(taken.tolist())
assert numbers.max() < ids.LIMIT
+55 -13
View File
@@ -15,6 +15,13 @@ def mean_by_weights(values: tuple[int, ...], weights: tuple[int, ...]) -> float:
return weighted / sum(weights)
def active_days(one_shot_percent: int, return_weights: tuple[int, ...]) -> float:
"""Сколько дней в среднем ходит кука: день рождения плюс возвраты."""
counts = tuple(range(1, len(return_weights) + 1))
returns = mean_by_weights(counts, return_weights)
return 1 + (100 - one_shot_percent) / 100 * returns
def test_origin_is_a_monday():
"""День недели считается как остаток номера дня — это верно от понедельника."""
assert world.ORIGIN.weekday() == 0
@@ -36,10 +43,7 @@ def test_returns_average_three_to_four():
def test_average_cookie_is_active_about_two_days():
"""Сходимость с разделом 5: ≈1,9 активного дня на куку — отсюда 6–8 тыс."""
counts = tuple(range(1, len(world.RETURN_COUNT_WEIGHTS) + 1))
returns = mean_by_weights(counts, world.RETURN_COUNT_WEIGHTS)
active_days = 1 + (100 - world.ONE_SHOT_PERCENT) / 100 * returns
assert 1.8 <= active_days <= 2.0
assert 1.8 <= active_days(world.ONE_SHOT_PERCENT, world.RETURN_COUNT_WEIGHTS) <= 2.0
def test_return_delays_cover_the_whole_activity_window():
@@ -108,12 +112,50 @@ def test_pauses_stay_inside_the_visit_timeout():
assert max(world.LONG_PAUSE_SECONDS) < world.VISIT_TIMEOUT_SECONDS
def test_the_funnel_converts_about_two_percent_of_visits():
"""Конверсия ~2% на сессию (спека, раздел 9) — произведение трёх шагов."""
conversion = (
world.CART_PERCENT
* world.CHECKOUT_OF_CART_PERCENT
* world.CONFIRMATION_OF_CHECKOUT_PERCENT
/ 100**2
)
assert 1.5 <= conversion <= 2.5
def test_the_trade_event_never_overtakes_the_next_page():
"""Торговое событие отстаёт от своей страницы меньше, чем длится пауза.
На этом стоят два свойства сразу: событие корзины не обгоняет страницу,
на которой посетитель нажал кнопку, и не выходит за полночь за ним в
том же визите всегда идёт страница, которая полночь пережила.
"""
assert max(world.TRADE_DELAY_SECONDS) <= min(world.PAGE_PAUSE_SECONDS)
def conversion(cart: int, checkout_of_cart: int) -> float:
"""Конверсия визита в заказ, проценты: произведение трёх шагов воронки."""
return cart * checkout_of_cart * world.CONFIRMATION_OF_CHECKOUT_PERCENT / 100**2
def test_the_ordinary_visitor_converts_a_bit_below_the_world():
"""Воронка обычного посетителя: ~1,5% визитов в оформленный заказ.
До ~2% конверсию мира (спека, раздел 9) добирают помеченные покупатели
и обещанные планом заказы пар, поэтому здесь сторожится слагаемое, а не
итог: сам итог меряет день-функция на собранном дне
(`test_the_shop_stays_the_same_plausible_shop`).
"""
assert 1.2 <= conversion(world.CART_PERCENT, world.CHECKOUT_OF_CART_PERCENT) <= 1.8
def test_the_buyer_mark_shows_on_both_steps_of_the_funnel():
"""Один шаг дал бы половину картины: кладут реже и бросают чаще оба."""
assert world.BUYER_CART_PERCENT > world.CART_PERCENT
assert world.BUYER_CHECKOUT_OF_CART_PERCENT > world.CHECKOUT_OF_CART_PERCENT
lift = conversion(
world.BUYER_CART_PERCENT, world.BUYER_CHECKOUT_OF_CART_PERCENT
) / conversion(world.CART_PERCENT, world.CHECKOUT_OF_CART_PERCENT)
# Заметно чаще прочих, но не «покупают только помеченные»: 5% людей дали
# бы тогда около 40 заказов в день вместо 240 (спека, раздел 9).
assert 2 <= lift <= 6
def test_the_buyer_mark_makes_the_cookie_live_longer():
"""Второй рычаг метки: без долгой жизни второй покупке негде случиться."""
assert world.BUYER_ONE_SHOT_PERCENT < world.ONE_SHOT_PERCENT
usual = active_days(world.ONE_SHOT_PERCENT, world.RETURN_COUNT_WEIGHTS)
buyer = active_days(world.BUYER_ONE_SHOT_PERCENT, world.BUYER_RETURN_COUNT_WEIGHTS)
assert buyer > 2 * usual
# Но не бессмертие: дневная аудитория остаётся в вилке 6–8 тыс., а
# помеченных всего 5% людей когорты.
assert buyer < world.RETURN_TAIL_DAYS / 10
+28 -1
View File
@@ -8,6 +8,7 @@ version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "numpy" },
{ name = "orjson" },
]
[package.dev-dependencies]
@@ -18,7 +19,10 @@ dev = [
]
[package.metadata]
requires-dist = [{ name = "numpy", specifier = ">=2" }]
requires-dist = [
{ name = "numpy", specifier = ">=2" },
{ name = "orjson", specifier = ">=3.11.9" },
]
[package.metadata.requires-dev]
dev = [
@@ -74,6 +78,29 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" },
]
[[package]]
name = "orjson"
version = "3.11.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" },
{ url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" },
{ url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" },
{ url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" },
{ url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" },
{ url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" },
{ url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" },
{ url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" },
{ url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" },
{ url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" },
{ url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" },
{ url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" },
{ url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" },
{ url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" },
{ url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" },
]
[[package]]
name = "packaging"
version = "26.2"