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:
+75
-75
@@ -1,60 +1,60 @@
|
|||||||
sku,name,category,brand,price
|
sku,name,category,brand,price
|
||||||
HOME-0001,Комплект органайзеров для шкафа,Товары для дома,Домовит,129000
|
HOME-0001,Комплект органайзеров для шкафа,Товары для дома,Домовит,129000
|
||||||
HOME-0002,Сушилка для белья напольная,Товары для дома,Домовит,249900
|
HOME-0002,Сушилка для белья напольная,Товары для дома,Домовит,249900
|
||||||
HOME-0003,Настольная лампа «Тихий вечер»,Товары для дома,Ладный дом,189000
|
HOME-0003,Настольная лампа «Тихий вечер»,Товары для дома,Ладный дом,188990
|
||||||
HOME-0004,Набор вешалок 12 штук,Товары для дома,Домовит,79000
|
HOME-0004,Набор вешалок 12 штук,Товары для дома,Домовит,79000
|
||||||
TEXT-0001,Комплект постельного белья полутораспальный,Текстиль,Сатин,349000
|
TEXT-0001,Комплект постельного белья полутораспальный,Текстиль,Сатин,348990
|
||||||
TEXT-0002,Плед-покрывало 200×220,Текстиль,Сатин,279000
|
TEXT-0002,Плед-покрывало 200×220,Текстиль,Сатин,279000
|
||||||
TEXT-0003,Полотенце банное 70×140,Текстиль,Сатин,89000
|
TEXT-0003,Полотенце банное 70×140,Текстиль,Сатин,89000
|
||||||
TEXT-0004,Штора блэкаут 200×260,Текстиль,Ладный дом,219000
|
TEXT-0004,Штора блэкаут 200×260,Текстиль,Ладный дом,218990
|
||||||
POSU-0001,Сковорода 26 см с антипригарным покрытием,Посуда,Кухмастер,199000
|
POSU-0001,Сковорода 26 см с антипригарным покрытием,Посуда,Кухмастер,198990
|
||||||
POSU-0002,Набор кастрюль 3 предмета,Посуда,Кухмастер,459000
|
POSU-0002,Набор кастрюль 3 предмета,Посуда,Кухмастер,458999
|
||||||
POSU-0003,Чайник заварочный стеклянный 1 л,Посуда,Кухмастер,119000
|
POSU-0003,Чайник заварочный стеклянный 1 л,Посуда,Кухмастер,118990
|
||||||
POSU-0004,Набор столовых приборов 24 предмета,Посуда,Ладья,289000
|
POSU-0004,Набор столовых приборов 24 предмета,Посуда,Ладья,289000
|
||||||
TECH-0001,Электрочайник 1.7 л,Бытовая техника,Электрон,189000
|
TECH-0001,Электрочайник 1.7 л,Бытовая техника,Электрон,188990
|
||||||
TECH-0002,Блендер погружной 800 Вт,Бытовая техника,Электрон,279000
|
TECH-0002,Блендер погружной 800 Вт,Бытовая техника,Электрон,279000
|
||||||
TECH-0003,Отпариватель ручной,Бытовая техника,Электрон,349000
|
TECH-0003,Отпариватель ручной,Бытовая техника,Электрон,349000
|
||||||
TECH-0004,Робот-пылесос,Бытовая техника,Электрон,1899000
|
TECH-0004,Робот-пылесос,Бытовая техника,Электрон,1899000
|
||||||
KIDS-0001,Стульчик для кормления,Детские товары,Малышок,649000
|
KIDS-0001,Стульчик для кормления,Детские товары,Малышок,649000
|
||||||
KIDS-0002,Ночник-проектор «Звёзды»,Детские товары,Малышок,129000
|
KIDS-0002,Ночник-проектор «Звёзды»,Детские товары,Малышок,128990
|
||||||
KIDS-0003,Набор детской посуды 5 предметов,Детские товары,Малышок,89000
|
KIDS-0003,Набор детской посуды 5 предметов,Детские товары,Малышок,88999
|
||||||
WEAR-0001,Тапочки домашние войлочные,Одежда и обувь,Ходики,99000
|
WEAR-0001,Тапочки домашние войлочные,Одежда и обувь,Ходики,98990
|
||||||
WEAR-0002,Халат махровый,Одежда и обувь,Сатин,249000
|
WEAR-0002,Халат махровый,Одежда и обувь,Сатин,249000
|
||||||
WEAR-0003,Носки хлопковые 5 пар,Одежда и обувь,Ходики,59000
|
WEAR-0003,Носки хлопковые 5 пар,Одежда и обувь,Ходики,59000
|
||||||
HOME-0005,Корзина для белья 45 л,Товары для дома,Домовит,119000
|
HOME-0005,Корзина для белья 45 л,Товары для дома,Домовит,119000
|
||||||
HOME-0006,Контейнер для хранения 18 л,Товары для дома,Домовит,69000
|
HOME-0006,Контейнер для хранения 18 л,Товары для дома,Домовит,69000
|
||||||
HOME-0007,Чехол для одежды 60×100,Товары для дома,Ладный дом,39000
|
HOME-0007,Чехол для одежды 60×100,Товары для дома,Ладный дом,38990
|
||||||
HOME-0008,Коврик придверный резиновый 40×60,Товары для дома,Домовит,59000
|
HOME-0008,Коврик придверный резиновый 40×60,Товары для дома,Домовит,59000
|
||||||
HOME-0009,Гладильная доска настольная 73×30,Товары для дома,Ладный дом,149000
|
HOME-0009,Гладильная доска настольная 73×30,Товары для дома,Ладный дом,149000
|
||||||
HOME-0010,Ведро хозяйственное с крышкой 12 л,Товары для дома,Домовит,49000
|
HOME-0010,Ведро хозяйственное с крышкой 12 л,Товары для дома,Домовит,48990
|
||||||
HOME-0011,Швабра с отжимом и ведром 8 л,Товары для дома,Домовит,179000
|
HOME-0011,Швабра с отжимом и ведром 8 л,Товары для дома,Домовит,178990
|
||||||
HOME-0012,Щётка для пола с черенком 120 см,Товары для дома,Домовит,45000
|
HOME-0012,Щётка для пола с черенком 120 см,Товары для дома,Домовит,45000
|
||||||
HOME-0013,Совок со щёткой на длинной ручке,Товары для дома,Домовит,79000
|
HOME-0013,Совок со щёткой на длинной ручке,Товары для дома,Домовит,79000
|
||||||
HOME-0014,Таз хозяйственный круглый 14 л,Товары для дома,Домовит,39000
|
HOME-0014,Таз хозяйственный круглый 14 л,Товары для дома,Домовит,38990
|
||||||
HOME-0015,Этажерка для ванной 3 яруса,Товары для дома,Ладный дом,139000
|
HOME-0015,Этажерка для ванной 3 яруса,Товары для дома,Ладный дом,139000
|
||||||
HOME-0016,Полка настенная металлическая 45 см,Товары для дома,Ладный дом,99000
|
HOME-0016,Полка настенная металлическая 45 см,Товары для дома,Ладный дом,99000
|
||||||
HOME-0017,Зеркало настенное круглое 50 см,Товары для дома,Ладный дом,169000
|
HOME-0017,Зеркало настенное круглое 50 см,Товары для дома,Ладный дом,168990
|
||||||
HOME-0018,Набор коробок для обуви 6 штук,Товары для дома,Домовит,129000
|
HOME-0018,Набор коробок для обуви 6 штук,Товары для дома,Домовит,129000
|
||||||
HOME-0019,Подставка для зонтов металлическая,Товары для дома,Ладный дом,159000
|
HOME-0019,Подставка для зонтов металлическая,Товары для дома,Ладный дом,158990
|
||||||
HOME-0020,Крючки самоклеящиеся 8 штук,Товары для дома,Домовит,29000
|
HOME-0020,Крючки самоклеящиеся 8 штук,Товары для дома,Домовит,29000
|
||||||
HOME-0021,Верёвка бельевая 20 м,Товары для дома,Домовит,19000
|
HOME-0021,Верёвка бельевая 20 м,Товары для дома,Домовит,19000
|
||||||
HOME-0022,Прищепки усиленные 24 штуки,Товары для дома,Домовит,25000
|
HOME-0022,Прищепки усиленные 24 штуки,Товары для дома,Домовит,24990
|
||||||
HOME-0023,Мешки вакуумные для одежды 5 штук,Товары для дома,Домовит,69000
|
HOME-0023,Мешки вакуумные для одежды 5 штук,Товары для дома,Домовит,68999
|
||||||
HOME-0024,Органайзер подвесной с 12 карманами,Товары для дома,Ладный дом,79000
|
HOME-0024,Органайзер подвесной с 12 карманами,Товары для дома,Ладный дом,79000
|
||||||
HOME-0025,Часы настенные круглые 30 см,Товары для дома,Ладный дом,89000
|
HOME-0025,Часы настенные круглые 30 см,Товары для дома,Ладный дом,88990
|
||||||
HOME-0026,Светильник настольный с прищепкой 7 Вт,Товары для дома,Ладный дом,119000
|
HOME-0026,Светильник настольный с прищепкой 7 Вт,Товары для дома,Ладный дом,119000
|
||||||
HOME-0027,Удлинитель бытовой 3 розетки 3 м,Товары для дома,Ладный дом,99000
|
HOME-0027,Удлинитель бытовой 3 розетки 3 м,Товары для дома,Ладный дом,99000
|
||||||
HOME-0028,Лестница-стремянка 3 ступени,Товары для дома,Домовит,299000
|
HOME-0028,Лестница-стремянка 3 ступени,Товары для дома,Домовит,299000
|
||||||
HOME-0029,Ящик для инструментов 16 дюймов,Товары для дома,Домовит,139000
|
HOME-0029,Ящик для инструментов 16 дюймов,Товары для дома,Домовит,138990
|
||||||
HOME-0030,Набор плечиков для брюк 6 штук,Товары для дома,Домовит,89000
|
HOME-0030,Набор плечиков для брюк 6 штук,Товары для дома,Домовит,89000
|
||||||
TEXT-0005,Пододеяльник двуспальный 175×215,Текстиль,Сатин,169000
|
TEXT-0005,Пододеяльник двуспальный 175×215,Текстиль,Сатин,169000
|
||||||
TEXT-0006,Простыня на резинке 160×200,Текстиль,Сатин,149000
|
TEXT-0006,Простыня на резинке 160×200,Текстиль,Сатин,148990
|
||||||
TEXT-0007,Наволочки хлопковые 50×70 2 штуки,Текстиль,Сатин,79000
|
TEXT-0007,Наволочки хлопковые 50×70 2 штуки,Текстиль,Сатин,79000
|
||||||
TEXT-0008,Одеяло всесезонное 172×205,Текстиль,Сатин,299000
|
TEXT-0008,Одеяло всесезонное 172×205,Текстиль,Сатин,299000
|
||||||
TEXT-0009,Подушка стёганая 50×70,Текстиль,Сатин,129000
|
TEXT-0009,Подушка стёганая 50×70,Текстиль,Сатин,128990
|
||||||
TEXT-0010,Наматрасник непромокаемый 160×200,Текстиль,Сатин,189000
|
TEXT-0010,Наматрасник непромокаемый 160×200,Текстиль,Сатин,189000
|
||||||
TEXT-0011,Покрывало стёганое 180×210,Текстиль,Сатин,239000
|
TEXT-0011,Покрывало стёганое 180×210,Текстиль,Сатин,238999
|
||||||
TEXT-0012,Плед флисовый 150×200,Текстиль,Сатин,119000
|
TEXT-0012,Плед флисовый 150×200,Текстиль,Сатин,118990
|
||||||
TEXT-0013,Полотенце лицевое 50×90,Текстиль,Сатин,49000
|
TEXT-0013,Полотенце лицевое 50×90,Текстиль,Сатин,49000
|
||||||
TEXT-0014,Полотенца кухонные 40×60 3 штуки,Текстиль,Сатин,59000
|
TEXT-0014,Полотенца кухонные 40×60 3 штуки,Текстиль,Сатин,59000
|
||||||
TEXT-0015,Коврик для ванной хлопковый 50×80,Текстиль,Ладный дом,99000
|
TEXT-0015,Коврик для ванной хлопковый 50×80,Текстиль,Ладный дом,99000
|
||||||
@@ -64,47 +64,47 @@ TEXT-0018,Шторы кухонные 150×170 2 полотна,Текстиль
|
|||||||
TEXT-0019,Скатерть водоотталкивающая 140×180,Текстиль,Ладный дом,129000
|
TEXT-0019,Скатерть водоотталкивающая 140×180,Текстиль,Ладный дом,129000
|
||||||
TEXT-0020,Дорожка на стол хлопковая 40×140,Текстиль,Ладный дом,59000
|
TEXT-0020,Дорожка на стол хлопковая 40×140,Текстиль,Ладный дом,59000
|
||||||
TEXT-0021,Салфетки столовые льняные 4 штуки,Текстиль,Сатин,79000
|
TEXT-0021,Салфетки столовые льняные 4 штуки,Текстиль,Сатин,79000
|
||||||
TEXT-0022,Чехлы на табурет 35×35 4 штуки,Текстиль,Ладный дом,109000
|
TEXT-0022,Чехлы на табурет 35×35 4 штуки,Текстиль,Ладный дом,108990
|
||||||
TEXT-0023,Подушка декоративная 40×40,Текстиль,Сатин,69000
|
TEXT-0023,Подушка декоративная 40×40,Текстиль,Сатин,68999
|
||||||
TEXT-0024,Чехол для диванной подушки 45×45,Текстиль,Сатин,39000
|
TEXT-0024,Чехол для диванной подушки 45×45,Текстиль,Сатин,39000
|
||||||
TEXT-0025,Фартук кухонный с карманом,Текстиль,Ладный дом,59000
|
TEXT-0025,Фартук кухонный с карманом,Текстиль,Ладный дом,59000
|
||||||
TEXT-0026,Прихватки стёганые 20×20 2 штуки,Текстиль,Ладный дом,39000
|
TEXT-0026,Прихватки стёганые 20×20 2 штуки,Текстиль,Ладный дом,38990
|
||||||
TEXT-0027,Халат вафельный размер 48–50,Текстиль,Сатин,199000
|
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-0029,Одеяло детское байковое 100×140,Текстиль,Сатин,169000
|
||||||
TEXT-0030,Подушка для стула 40×40,Текстиль,Ладный дом,59000
|
TEXT-0030,Подушка для стула 40×40,Текстиль,Ладный дом,59000
|
||||||
POSU-0005,Кастрюля эмалированная 3 л,Посуда,Кухмастер,169000
|
POSU-0005,Кастрюля эмалированная 3 л,Посуда,Кухмастер,168990
|
||||||
POSU-0006,Ковш нержавеющий с крышкой 1.5 л,Посуда,Кухмастер,139000
|
POSU-0006,Ковш нержавеющий с крышкой 1.5 л,Посуда,Кухмастер,138990
|
||||||
POSU-0007,Сотейник литой с крышкой 28 см,Посуда,Кухмастер,269000
|
POSU-0007,Сотейник литой с крышкой 28 см,Посуда,Кухмастер,269000
|
||||||
POSU-0008,Сковорода блинная 22 см,Посуда,Кухмастер,149000
|
POSU-0008,Сковорода блинная 22 см,Посуда,Кухмастер,149000
|
||||||
POSU-0009,Казан алюминиевый 6 л,Посуда,Кухмастер,299000
|
POSU-0009,Казан алюминиевый 6 л,Посуда,Кухмастер,299000
|
||||||
POSU-0010,Форма для запекания стеклянная 2.4 л,Посуда,Кухмастер,119000
|
POSU-0010,Форма для запекания стеклянная 2.4 л,Посуда,Кухмастер,118990
|
||||||
POSU-0011,Противень стальной 37×27,Посуда,Кухмастер,79000
|
POSU-0011,Противень стальной 37×27,Посуда,Кухмастер,79000
|
||||||
POSU-0012,Набор форм для выпечки 3 штуки,Посуда,Кухмастер,139000
|
POSU-0012,Набор форм для выпечки 3 штуки,Посуда,Кухмастер,139000
|
||||||
POSU-0013,Миска нержавеющая 24 см,Посуда,Кухмастер,69000
|
POSU-0013,Миска нержавеющая 24 см,Посуда,Кухмастер,68990
|
||||||
POSU-0014,Дуршлаг стальной с ручками 22 см,Посуда,Кухмастер,89000
|
POSU-0014,Дуршлаг стальной с ручками 22 см,Посуда,Кухмастер,89000
|
||||||
POSU-0015,Тёрка четырёхгранная 23 см,Посуда,Кухмастер,49000
|
POSU-0015,Тёрка четырёхгранная 23 см,Посуда,Кухмастер,49000
|
||||||
POSU-0016,Доска разделочная бамбуковая 35×25,Посуда,Кухмастер,79000
|
POSU-0016,Доска разделочная бамбуковая 35×25,Посуда,Кухмастер,78990
|
||||||
POSU-0017,Набор ножей с подставкой 6 предметов,Посуда,Кухмастер,249000
|
POSU-0017,Набор ножей с подставкой 6 предметов,Посуда,Кухмастер,248990
|
||||||
POSU-0018,Половник и шумовка 2 предмета,Посуда,Кухмастер,59000
|
POSU-0018,Половник и шумовка 2 предмета,Посуда,Кухмастер,58990
|
||||||
POSU-0019,Контейнеры пищевые 7 предметов,Посуда,Кухмастер,99000
|
POSU-0019,Контейнеры пищевые 7 предметов,Посуда,Кухмастер,99000
|
||||||
POSU-0020,Банки для сыпучих продуктов 4 штуки,Посуда,Ладья,139000
|
POSU-0020,Банки для сыпучих продуктов 4 штуки,Посуда,Ладья,138990
|
||||||
POSU-0021,Тарелки обеденные фарфоровые 6 штук,Посуда,Ладья,179000
|
POSU-0021,Тарелки обеденные фарфоровые 6 штук,Посуда,Ладья,178999
|
||||||
POSU-0022,Тарелки суповые керамические 4 штуки,Посуда,Ладья,129000
|
POSU-0022,Тарелки суповые керамические 4 штуки,Посуда,Ладья,129000
|
||||||
POSU-0023,Салатник стеклянный 23 см,Посуда,Ладья,69000
|
POSU-0023,Салатник стеклянный 23 см,Посуда,Ладья,68999
|
||||||
POSU-0024,Кружки керамические 350 мл 4 штуки,Посуда,Ладья,119000
|
POSU-0024,Кружки керамические 350 мл 4 штуки,Посуда,Ладья,118990
|
||||||
POSU-0025,Стаканы высокие 400 мл 6 штук,Посуда,Ладья,99000
|
POSU-0025,Стаканы высокие 400 мл 6 штук,Посуда,Ладья,98990
|
||||||
POSU-0026,Бокалы для воды 300 мл 6 штук,Посуда,Ладья,149000
|
POSU-0026,Бокалы для воды 300 мл 6 штук,Посуда,Ладья,149000
|
||||||
POSU-0027,Термос стальной 1 л,Посуда,Ладья,179000
|
POSU-0027,Термос стальной 1 л,Посуда,Ладья,179000
|
||||||
POSU-0028,Термокружка с крышкой 450 мл,Посуда,Ладья,99000
|
POSU-0028,Термокружка с крышкой 450 мл,Посуда,Ладья,99000
|
||||||
POSU-0029,Сахарница фарфоровая 400 мл,Посуда,Ладья,59000
|
POSU-0029,Сахарница фарфоровая 400 мл,Посуда,Ладья,59000
|
||||||
POSU-0030,Маслёнка стеклянная с крышкой,Посуда,Ладья,69000
|
POSU-0030,Маслёнка стеклянная с крышкой,Посуда,Ладья,68990
|
||||||
TECH-0005,Миксер ручной 500 Вт,Бытовая техника,Электрон,249000
|
TECH-0005,Миксер ручной 500 Вт,Бытовая техника,Электрон,248990
|
||||||
TECH-0006,Измельчитель с чашей 1.5 л,Бытовая техника,Электрон,299000
|
TECH-0006,Измельчитель с чашей 1.5 л,Бытовая техника,Электрон,298990
|
||||||
TECH-0007,Стационарный блендер 1.8 л,Бытовая техника,Электрон,549000
|
TECH-0007,Стационарный блендер 1.8 л,Бытовая техника,Электрон,549000
|
||||||
TECH-0008,Мясорубка электрическая 1800 Вт,Бытовая техника,Электрон,799000
|
TECH-0008,Мясорубка электрическая 1800 Вт,Бытовая техника,Электрон,799000
|
||||||
TECH-0009,Тостер на 2 ломтика 850 Вт,Бытовая техника,Электрон,299000
|
TECH-0009,Тостер на 2 ломтика 850 Вт,Бытовая техника,Электрон,299000
|
||||||
TECH-0010,Вафельница электрическая 1000 Вт,Бытовая техника,Электрон,349000
|
TECH-0010,Вафельница электрическая 1000 Вт,Бытовая техника,Электрон,348999
|
||||||
TECH-0011,Мультиварка с чашей 5 л,Бытовая техника,Электрон,699000
|
TECH-0011,Мультиварка с чашей 5 л,Бытовая техника,Электрон,699000
|
||||||
TECH-0012,Электроплитка на 1 конфорку 1500 Вт,Бытовая техника,Электрон,249000
|
TECH-0012,Электроплитка на 1 конфорку 1500 Вт,Бытовая техника,Электрон,249000
|
||||||
TECH-0013,Мини-печь с грилем 30 л,Бытовая техника,Электрон,899000
|
TECH-0013,Мини-печь с грилем 30 л,Бытовая техника,Электрон,899000
|
||||||
@@ -121,61 +121,61 @@ TECH-0023,Электровеник с контейнером 0.5 л,Бытова
|
|||||||
TECH-0024,Фен с диффузором 2200 Вт,Бытовая техника,Электрон,299000
|
TECH-0024,Фен с диффузором 2200 Вт,Бытовая техника,Электрон,299000
|
||||||
TECH-0025,Щипцы для волос 45 Вт,Бытовая техника,Электрон,249000
|
TECH-0025,Щипцы для волос 45 Вт,Бытовая техника,Электрон,249000
|
||||||
TECH-0026,Машинка для стрижки с 6 насадками,Бытовая техника,Электрон,399000
|
TECH-0026,Машинка для стрижки с 6 насадками,Бытовая техника,Электрон,399000
|
||||||
TECH-0027,Весы напольные до 180 кг,Бытовая техника,Электрон,199000
|
TECH-0027,Весы напольные до 180 кг,Бытовая техника,Электрон,198990
|
||||||
TECH-0028,Весы кухонные до 5 кг,Бытовая техника,Электрон,149000
|
TECH-0028,Весы кухонные до 5 кг,Бытовая техника,Электрон,148990
|
||||||
TECH-0029,Увлажнитель воздуха 3 л,Бытовая техника,Электрон,499000
|
TECH-0029,Увлажнитель воздуха 3 л,Бытовая техника,Электрон,499000
|
||||||
TECH-0030,Тепловентилятор керамический 1500 Вт,Бытовая техника,Электрон,399000
|
TECH-0030,Тепловентилятор керамический 1500 Вт,Бытовая техника,Электрон,398990
|
||||||
KIDS-0004,Кроватка кукольная деревянная 45 см,Детские товары,Малышок,249000
|
KIDS-0004,Кроватка кукольная деревянная 45 см,Детские товары,Малышок,248999
|
||||||
KIDS-0005,Манеж складной 100×100,Детские товары,Малышок,799000
|
KIDS-0005,Манеж складной 100×100,Детские товары,Малышок,798990
|
||||||
KIDS-0006,Горшок детский со спинкой,Детские товары,Малышок,79000
|
KIDS-0006,Горшок детский со спинкой,Детские товары,Малышок,79000
|
||||||
KIDS-0007,Ванночка детская 84 см,Детские товары,Малышок,169000
|
KIDS-0007,Ванночка детская 84 см,Детские товары,Малышок,168990
|
||||||
KIDS-0008,Подставка детская двухступенчатая,Детские товары,Малышок,89000
|
KIDS-0008,Подставка детская двухступенчатая,Детские товары,Малышок,89000
|
||||||
KIDS-0009,Накладка на унитаз с ручками,Детские товары,Малышок,69000
|
KIDS-0009,Накладка на унитаз с ручками,Детские товары,Малышок,69000
|
||||||
KIDS-0010,Сиденье для купания на присосках,Детские товары,Малышок,139000
|
KIDS-0010,Сиденье для купания на присосках,Детские товары,Малышок,138990
|
||||||
KIDS-0011,Пелёнки муслиновые 80×100 3 штуки,Детские товары,Малышок,129000
|
KIDS-0011,Пелёнки муслиновые 80×100 3 штуки,Детские товары,Малышок,129000
|
||||||
KIDS-0012,Одеяло для коляски 90×110,Детские товары,Малышок,149000
|
KIDS-0012,Одеяло для коляски 90×110,Детские товары,Малышок,148990
|
||||||
KIDS-0013,Нагрудники непромокаемые 5 штук,Детские товары,Малышок,59000
|
KIDS-0013,Нагрудники непромокаемые 5 штук,Детские товары,Малышок,58999
|
||||||
KIDS-0014,Бутылочка с ручками 250 мл,Детские товары,Малышок,49000
|
KIDS-0014,Бутылочка с ручками 250 мл,Детские товары,Малышок,49000
|
||||||
KIDS-0015,Поильник непроливайка 300 мл,Детские товары,Малышок,55000
|
KIDS-0015,Поильник непроливайка 300 мл,Детские товары,Малышок,54990
|
||||||
KIDS-0016,Контейнер для пустышки 2 штуки,Детские товары,Малышок,25000
|
KIDS-0016,Контейнер для пустышки 2 штуки,Детские товары,Малышок,25000
|
||||||
KIDS-0017,Погремушки мягкие 3 штуки,Детские товары,Малышок,59000
|
KIDS-0017,Погремушки мягкие 3 штуки,Детские товары,Малышок,59000
|
||||||
KIDS-0018,Кубики развивающие 12 штук,Детские товары,Малышок,89000
|
KIDS-0018,Кубики развивающие 12 штук,Детские товары,Малышок,89000
|
||||||
KIDS-0019,Пирамидка деревянная 8 колец,Детские товары,Малышок,79000
|
KIDS-0019,Пирамидка деревянная 8 колец,Детские товары,Малышок,78990
|
||||||
KIDS-0020,Сортер с геометрическими фигурами,Детские товары,Малышок,99000
|
KIDS-0020,Сортер с геометрическими фигурами,Детские товары,Малышок,98990
|
||||||
KIDS-0021,Конструктор деревянный 50 деталей,Детские товары,Малышок,139000
|
KIDS-0021,Конструктор деревянный 50 деталей,Детские товары,Малышок,139000
|
||||||
KIDS-0022,Доска для рисования магнитная 30×40,Детские товары,Малышок,129000
|
KIDS-0022,Доска для рисования магнитная 30×40,Детские товары,Малышок,128990
|
||||||
KIDS-0023,Мольберт детский двусторонний 105 см,Детские товары,Малышок,299000
|
KIDS-0023,Мольберт детский двусторонний 105 см,Детские товары,Малышок,298999
|
||||||
KIDS-0024,Набор для лепки 12 цветов,Детские товары,Малышок,69000
|
KIDS-0024,Набор для лепки 12 цветов,Детские товары,Малышок,69000
|
||||||
KIDS-0025,Коврик игровой складной 180×150,Детские товары,Малышок,399000
|
KIDS-0025,Коврик игровой складной 180×150,Детские товары,Малышок,399000
|
||||||
KIDS-0026,Палатка игровая с окошками,Детские товары,Малышок,199000
|
KIDS-0026,Палатка игровая с окошками,Детские товары,Малышок,199000
|
||||||
KIDS-0027,Каталка с ручкой и звуком,Детские товары,Малышок,169000
|
KIDS-0027,Каталка с ручкой и звуком,Детские товары,Малышок,169000
|
||||||
KIDS-0028,Самокат трёхколёсный до 50 кг,Детские товары,Малышок,299000
|
KIDS-0028,Самокат трёхколёсный до 50 кг,Детские товары,Малышок,298999
|
||||||
KIDS-0029,Санки со спинкой и ремнём,Детские товары,Малышок,349000
|
KIDS-0029,Санки со спинкой и ремнём,Детские товары,Малышок,349000
|
||||||
KIDS-0030,Рюкзак детский объёмом 8 л,Детские товары,Малышок,129000
|
KIDS-0030,Рюкзак детский объёмом 8 л,Детские товары,Малышок,128990
|
||||||
WEAR-0004,Футболка хлопковая размер 48,Одежда и обувь,Сатин,79000
|
WEAR-0004,Футболка хлопковая размер 48,Одежда и обувь,Сатин,78990
|
||||||
WEAR-0005,Майка хлопковая размер 46,Одежда и обувь,Сатин,59000
|
WEAR-0005,Майка хлопковая размер 46,Одежда и обувь,Сатин,58990
|
||||||
WEAR-0006,Лонгслив трикотажный размер 50,Одежда и обувь,Сатин,109000
|
WEAR-0006,Лонгслив трикотажный размер 50,Одежда и обувь,Сатин,108990
|
||||||
WEAR-0007,Водолазка хлопковая размер 44,Одежда и обувь,Сатин,119000
|
WEAR-0007,Водолазка хлопковая размер 44,Одежда и обувь,Сатин,118990
|
||||||
WEAR-0008,Толстовка с капюшоном размер 52,Одежда и обувь,Сатин,199000
|
WEAR-0008,Толстовка с капюшоном размер 52,Одежда и обувь,Сатин,198999
|
||||||
WEAR-0009,Брюки спортивные размер 50,Одежда и обувь,Сатин,169000
|
WEAR-0009,Брюки спортивные размер 50,Одежда и обувь,Сатин,169000
|
||||||
WEAR-0010,Легинсы хлопковые размер 46,Одежда и обувь,Сатин,99000
|
WEAR-0010,Легинсы хлопковые размер 46,Одежда и обувь,Сатин,98990
|
||||||
WEAR-0011,Пижама трикотажная размер 48,Одежда и обувь,Сатин,179000
|
WEAR-0011,Пижама трикотажная размер 48,Одежда и обувь,Сатин,179000
|
||||||
WEAR-0012,Ночная сорочка хлопковая размер 50,Одежда и обувь,Сатин,129000
|
WEAR-0012,Ночная сорочка хлопковая размер 50,Одежда и обувь,Сатин,128999
|
||||||
WEAR-0013,Фартук рабочий с тремя карманами,Одежда и обувь,Сатин,79000
|
WEAR-0013,Фартук рабочий с тремя карманами,Одежда и обувь,Сатин,79000
|
||||||
WEAR-0014,Перчатки хозяйственные 3 пары,Одежда и обувь,Ходики,39000
|
WEAR-0014,Перчатки хозяйственные 3 пары,Одежда и обувь,Ходики,39000
|
||||||
WEAR-0015,Варежки вязаные размер 8,Одежда и обувь,Ходики,69000
|
WEAR-0015,Варежки вязаные размер 8,Одежда и обувь,Ходики,68990
|
||||||
WEAR-0016,Шапка вязаная с отворотом размер 56,Одежда и обувь,Ходики,89000
|
WEAR-0016,Шапка вязаная с отворотом размер 56,Одежда и обувь,Ходики,88990
|
||||||
WEAR-0017,Шарф шерстяной 30×180,Одежда и обувь,Ходики,119000
|
WEAR-0017,Шарф шерстяной 30×180,Одежда и обувь,Ходики,119000
|
||||||
WEAR-0018,Ремень текстильный длиной 120 см,Одежда и обувь,Ходики,79000
|
WEAR-0018,Ремень текстильный длиной 120 см,Одежда и обувь,Ходики,78990
|
||||||
WEAR-0019,Стельки войлочные 2 пары,Одежда и обувь,Ходики,39000
|
WEAR-0019,Стельки войлочные 2 пары,Одежда и обувь,Ходики,38990
|
||||||
WEAR-0020,Шнурки круглые 120 см 3 пары,Одежда и обувь,Ходики,25000
|
WEAR-0020,Шнурки круглые 120 см 3 пары,Одежда и обувь,Ходики,25000
|
||||||
WEAR-0021,Кеды текстильные размер 42,Одежда и обувь,Ходики,179000
|
WEAR-0021,Кеды текстильные размер 42,Одежда и обувь,Ходики,179000
|
||||||
WEAR-0022,Кроссовки прогулочные размер 39,Одежда и обувь,Ходики,249000
|
WEAR-0022,Кроссовки прогулочные размер 39,Одежда и обувь,Ходики,248990
|
||||||
WEAR-0023,Ботинки утеплённые размер 41,Одежда и обувь,Ходики,349000
|
WEAR-0023,Ботинки утеплённые размер 41,Одежда и обувь,Ходики,348990
|
||||||
WEAR-0024,Сапоги резиновые размер 40,Одежда и обувь,Ходики,199000
|
WEAR-0024,Сапоги резиновые размер 40,Одежда и обувь,Ходики,199000
|
||||||
WEAR-0025,Полуботинки кожаные размер 43,Одежда и обувь,Ходики,399000
|
WEAR-0025,Полуботинки кожаные размер 43,Одежда и обувь,Ходики,398999
|
||||||
WEAR-0026,Сандалии с ремешками размер 38,Одежда и обувь,Ходики,189000
|
WEAR-0026,Сандалии с ремешками размер 38,Одежда и обувь,Ходики,189000
|
||||||
WEAR-0027,Сланцы резиновые размер 41,Одежда и обувь,Ходики,79000
|
WEAR-0027,Сланцы резиновые размер 41,Одежда и обувь,Ходики,79000
|
||||||
WEAR-0028,Балетки текстильные размер 37,Одежда и обувь,Ходики,149000
|
WEAR-0028,Балетки текстильные размер 37,Одежда и обувь,Ходики,149000
|
||||||
WEAR-0029,Туфли домашние утеплённые размер 39,Одежда и обувь,Ходики,129000
|
WEAR-0029,Туфли домашние утеплённые размер 39,Одежда и обувь,Ходики,129000
|
||||||
WEAR-0030,Галоши садовые размер 42,Одежда и обувь,Ходики,99000
|
WEAR-0030,Галоши садовые размер 42,Одежда и обувь,Ходики,98990
|
||||||
|
|||||||
|
@@ -5,8 +5,10 @@
|
|||||||
пересобрать: `make docs`.
|
пересобрать: `make docs`.
|
||||||
|
|
||||||
Одно событие — одна строка: хит по образцу облачной выгрузки Яндекс Метрики.
|
Одно событие — одна строка: хит по образцу облачной выгрузки Яндекс Метрики.
|
||||||
Многозначное лежит в параллельных массивах одной длины, плюс одно сырое
|
Многозначное лежит в параллельных массивах, плюс одно сырое JSON-поле
|
||||||
JSON-поле `ecommerce`. Отдельной сущности «визит» в выгрузке нет — визиты
|
`ecommerce`. Длина у массивов общая **внутри группы**, а не по всему
|
||||||
|
событию: `purchase*` — по элементу на заказ (у нас всегда один), `product*` —
|
||||||
|
по элементу на товар. Отдельной сущности «визит» в выгрузке нет — визиты
|
||||||
собирают на стороне хранилища, а `VisitID` дан как эталон для самопроверки.
|
собирают на стороне хранилища, а `VisitID` дан как эталон для самопроверки.
|
||||||
|
|
||||||
Имена и типы колонок — стороны источника. Хранилище принимает их как есть и
|
Имена и типы колонок — стороны источника. Хранилище принимает их как есть и
|
||||||
@@ -21,6 +23,10 @@ JSON-поле `ecommerce`. Отдельной сущности «визит» в
|
|||||||
`add_to_cart` несёт один товар, `purchase` — состав заказа и блок
|
`add_to_cart` несёт один товар, `purchase` — состав заказа и блок
|
||||||
`purchase*`. У остальных событий они пусты.
|
`purchase*`. У остальных событий они пусты.
|
||||||
|
|
||||||
|
Деньги: `productPrice` — целые рубли, округление формата. Точная сумма
|
||||||
|
заказа живёт в `purchaseRevenue` и в сыром `ecommerce`, поэтому пересчитать
|
||||||
|
выручку по разобранным массивам нельзя — цены каталога бывают с копейками.
|
||||||
|
|
||||||
Всего колонок: 47.
|
Всего колонок: 47.
|
||||||
|
|
||||||
## Идентификаторы и время
|
## Идентификаторы и время
|
||||||
|
|||||||
@@ -447,15 +447,18 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
|
|||||||
- **D0 = 2026-06-01, понедельник** — решение владельца при нарезке
|
- **D0 = 2026-06-01, понедельник** — решение владельца при нарезке
|
||||||
(2026-08-01). Дата недавняя, чтобы данные первые месяцы выглядели
|
(2026-08-01). Дата недавняя, чтобы данные первые месяцы выглядели
|
||||||
свежими; привязки к реальному календарю у констант мира всё равно нет.
|
свежими; привязки к реальному календарю у констант мира всё равно нет.
|
||||||
- **Измерено на собранном плане** (#38, канонический мир, 14 дней):
|
- **Измерено на собранном плане** (канонический мир, 14 дней; числа
|
||||||
приток 3 828 кук в день в среднем, дневная аудитория 6 235–7 124 —
|
перемерены при исполнении #40 — рычаг долгой жизни помеченного
|
||||||
обе величины в вилках раздела 5. Накопленная аудитория за снимок —
|
покупателя сдвинул состав): приток 3 828 кук в день в среднем, дневная
|
||||||
68 тыс. кук: 53,6 тыс. новыми плюс 14,5 тыс. возвратами предыстории
|
аудитория 6 637–7 595 — обе величины в вилках раздела 5. Накопленная
|
||||||
(при нарезке возвраты предыстории оценили вдвое скромнее — отсюда
|
аудитория за снимок — 70,2 тыс. кук: 53,6 тыс. новыми плюс 16,6 тыс.
|
||||||
ходившая раньше оценка ≈60 тыс.). Пар, у которых оба назначенных
|
возвратами предыстории (при нарезке возвраты предыстории оценили
|
||||||
заказа попали внутрь 14 дней, — 170; остальные пары горизонт
|
скромнее — отсюда ходившая раньше оценка ≈60 тыс.). Пар, у которых оба
|
||||||
|
назначенных заказа попали внутрь 14 дней, — 192; остальные пары горизонт
|
||||||
переживают, их вторая кука приходит позже. Цифры пересчитываются
|
переживают, их вторая кука приходит позже. Цифры пересчитываются
|
||||||
прогоном плана, событий для них не нужно.
|
прогоном плана, событий для них не нужно. До правки #40 те же измерения
|
||||||
|
давали: аудитория 6 235–7 124, накопленная 68,1 тыс. (14,5 тыс.
|
||||||
|
возвратами предыстории), 170 пар.
|
||||||
- **Конфигурация мира — модуль чистых данных** рядом с контрактом схемы:
|
- **Конфигурация мира — модуль чистых данных** рядом с контрактом схемы:
|
||||||
все числа мира в одном месте, написанном как приглашение любопытному
|
все числа мира в одном месте, написанном как приглашение любопытному
|
||||||
менти крутить. Правка модуля — смена мира: чек манифеста честно
|
менти крутить. Правка модуля — смена мира: чек манифеста честно
|
||||||
@@ -538,10 +541,14 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
|
|||||||
Правила лежат в докстринге `day.py`, и тест собирает визиты по ним
|
Правила лежат в докстринге `day.py`, и тест собирает визиты по ним
|
||||||
заново, сверяя сборку с `VisitID`.
|
заново, сверяя сборку с `VisitID`.
|
||||||
- **Числа дня**: ≈1,4 визита на день активности куки и ≈4,8 страницы на
|
- **Числа дня**: ≈1,4 визита на день активности куки и ≈4,8 страницы на
|
||||||
визит — 9,5 тыс. визитов и ~45 тыс. pageview в средний день; воронка
|
визит. Воронка при исполнении #39 была общей для всех: 8% визитов до
|
||||||
8% визитов до корзины → 45% из них до оформления → 55% из них до
|
корзины → 45% из них до оформления → 55% из них до подтверждения, то есть
|
||||||
подтверждения, то есть конверсия визита ~2%. Итог сложится после
|
конверсия визита ~2% при 9,5 тыс. визитов и ~45 тыс. pageview в средний
|
||||||
торговых событий (#40) и ляжет в манифест.
|
день. При исполнении #40 воронка развелась надвое (блок ниже): обычному
|
||||||
|
посетителю 6% до корзины и те же 45% и 55%, помеченному покупателю — 18%
|
||||||
|
и 60%. Перемеренный средний день: 9 879 визитов, 47 433 pageview, 4,80
|
||||||
|
страницы на визит, конверсия визита 2,4%; полные числа — в блоке #40,
|
||||||
|
оттуда же они лягут в манифест.
|
||||||
- **Недельная волна применяется один раз.** Профиль ведёт приток, трафик
|
- **Недельная волна применяется один раз.** Профиль ведёт приток, трафик
|
||||||
наследует его через дневную аудиторию; измеренный размах трафика — ±7%
|
наследует его через дневную аудиторию; измеренный размах трафика — ±7%
|
||||||
против ±10% у притока. Второе умножение удвоило бы недельный размах. Если
|
против ±10% у притока. Второе умножение удвоило бы недельный размах. Если
|
||||||
@@ -595,15 +602,24 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
|
|||||||
воронке (#39); правятся оба. Отличается помеченный на обоих шагах воронки:
|
воронке (#39); правятся оба. Отличается помеченный на обоих шагах воронки:
|
||||||
и до корзины доходит чаще, и бросает её реже. В жизни различаются оба — кто
|
и до корзины доходит чаще, и бросает её реже. В жизни различаются оба — кто
|
||||||
пришёл смотреть, тот и кладёт реже, и до конца доводит реже; один шаг дал
|
пришёл смотреть, тот и кладёт реже, и до конца доводит реже; один шаг дал
|
||||||
бы половину картины. Числа под каждый шаг — калибровка. Границы правки —
|
бы половину картины. Числа под каждый шаг — калибровка: помеченному 18% до
|
||||||
вилки раздела 5: дневная
|
корзины и 60% из корзины в оформление против 6% и 45% у обычного
|
||||||
аудитория остаётся в пределах 6–8 тыс., средний день — около 50 тыс.
|
посетителя. Обычного калибровка тоже задела — его доля до корзины опущена
|
||||||
событий, конверсия визита — около 2%. Расти внутри вилки аудитории
|
с 8% до 6%, и это единственное число мира, изменённое вне двух рычагов.
|
||||||
|
Довод: помеченные живут дольше и занимают уже 12% дневной аудитории вместо
|
||||||
|
5% людей когорты, поэтому при прежних 8% конверсия мира ушла бы к 2,9% —
|
||||||
|
за вилку. Рычаг метки не должен оплачиваться ростом конверсии, и границы
|
||||||
|
правки — вилки раздела 5: дневная аудитория остаётся в пределах
|
||||||
|
6–8 тыс., средний день — около 50 тыс. событий, конверсия визита —
|
||||||
|
около 2%. Расти внутри вилки аудитории
|
||||||
разрешено: помеченные ходят чаще, и это по-прежнему правдоподобный магазин.
|
разрешено: помеченные ходят чаще, и это по-прежнему правдоподобный магазин.
|
||||||
Измеренные числа состава (блок #38 выше) после правки перемеряются и
|
Измерено после правки (канонический мир, 14 дней): в средний день
|
||||||
переписываются; база до правки — в средний день аудитория 6 710, визитов
|
аудитория 7 166, визитов 9 879, событий 49 509, визитов с корзиной 765
|
||||||
9 249, событий 44 436, визитов с корзиной 780 (8,4%) и с покупкой 221
|
(7,7%) и с покупкой 240 (2,4%) — все три вилки выдержаны. База до правки
|
||||||
(2,4%). Уточнение к разделу 1: вольные покупки по-прежнему решает день, но
|
была: аудитория 6 710, визитов 9 249, событий 44 436, визитов с корзиной
|
||||||
|
780 (8,4%) и с покупкой 221 (2,4%). Числа состава мира (блок #38 выше)
|
||||||
|
перемерены тем же прогоном. Уточнение к разделу 1: вольные покупки
|
||||||
|
по-прежнему решает день, но
|
||||||
случайность в них больше не одинакова для всех. Отклонено: *оставить
|
случайность в них больше не одинакова для всех. Отклонено: *оставить
|
||||||
монетку* — «покупатель» остаётся именем без следа в данных; *один лифт без
|
монетку* — «покупатель» остаётся именем без следа в данных; *один лифт без
|
||||||
долгой жизни* — довод выше, повторных покупок он почти не прибавляет;
|
долгой жизни* — довод выше, повторных покупок он почти не прибавляет;
|
||||||
@@ -662,6 +678,46 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
|
|||||||
ломал бы его ровно там, ради чего написан. Визит, у которого страница
|
ломал бы его ровно там, ради чего написан. Визит, у которого страница
|
||||||
подтверждения срезана полуночью (1,2% покупающих), покупки не даёт — заказа
|
подтверждения срезана полуночью (1,2% покупающих), покупки не даёт — заказа
|
||||||
не было.
|
не было.
|
||||||
|
- **Счётчика покупок в плане состава не заводится.** Тикет просил «счётчики
|
||||||
|
покупок и двухкуковых пар сходятся с планом», но покупок план не считает и
|
||||||
|
считать не будет: раздел 1 обещает до генерации только состав — посетителей,
|
||||||
|
приток, пары, — а торговые счётчики складываются из поведения дня. Квота на
|
||||||
|
покупки заставила бы день добирать до точного числа, и конверсия в данных
|
||||||
|
вышла бы подозрительно ровной — условность видна там, куда лаба смотрит.
|
||||||
|
Сходятся пары; покупки проверяются постфактум по сгенерированному дню и по
|
||||||
|
вилке конверсии (~2% на визит).
|
||||||
|
|
||||||
|
Решено при исполнении #40 самой реализацией:
|
||||||
|
|
||||||
|
- **Торговый хвост разводит и соседние визиты, а не только сутки.** Правило
|
||||||
|
резки требует, чтобы визиты одной куки стояли дальше таймаута; считать это
|
||||||
|
расстояние от последней страницы, когда последним событием визита бывает
|
||||||
|
покупка, значило отдать таймауту торговый хвост — лаба сессий склеила бы
|
||||||
|
два визита в один и разошлась бы с `VisitID`. Расстояние считается от
|
||||||
|
последнего события; поймано тестом сборки визитов.
|
||||||
|
- **Полночь забирает подтверждение вместе с его покупкой.** Резерв суток
|
||||||
|
сначала считался только для визитов с обещанным планом заказом, и у
|
||||||
|
вольной покупки оставалось окно в несколько секунд: страница
|
||||||
|
подтверждения дожила до потока, а событие покупки за полночь вышло. Это
|
||||||
|
была бы потеря на клиентской стороне — класс этапов 4 и 6, заведённый по
|
||||||
|
недосмотру. Теперь запас под торговый хвост есть у всякой страницы
|
||||||
|
подтверждения: либо она уходит за границу суток вместе с покупкой, либо
|
||||||
|
остаются обе. Это ровно то правило, которое блок выше называет словами
|
||||||
|
«визит, у которого страница подтверждения срезана полуночью, покупки не
|
||||||
|
даёт»; в каноническом мире числа от правки не сдвинулись — случай не
|
||||||
|
выпал ни разу за 14 дней.
|
||||||
|
- **Задержка торгового события короче самой короткой паузы между
|
||||||
|
страницами.** Тогда событие корзины не обгоняет страницу, на которой
|
||||||
|
посетитель нажал кнопку, и не выходит за полночь: за ним в том же визите
|
||||||
|
всегда идёт страница, которая полночь пережила.
|
||||||
|
- **Номера торговых событий — из торгового подпотока.** `WatchID` строки
|
||||||
|
корзины и покупки рисует `COMMERCE`: возьми их у трафика, и правка
|
||||||
|
торгового поведения сдвинула бы трафиковые номера. Ряды двух подпотоков
|
||||||
|
проверяются на непересечение — обещание уникальности держит дедупликацию
|
||||||
|
при переигровке дня, и предполагать его нельзя.
|
||||||
|
- **Вариант товара — справочник мира.** Сырой `ecommerce` несёт `variant`,
|
||||||
|
которого в плоских колонках нет; строки живут в справочниках рядом с
|
||||||
|
устройствами и городами, а не сочиняются на месте.
|
||||||
|
|
||||||
Остаётся открытым, за тикетами:
|
Остаётся открытым, за тикетами:
|
||||||
|
|
||||||
|
|||||||
+8
-2
@@ -5,7 +5,8 @@
|
|||||||
спека [«Генератор (этап 2)»](../docs/specs/2026-08-01-generator.md).
|
спека [«Генератор (этап 2)»](../docs/specs/2026-08-01-generator.md).
|
||||||
|
|
||||||
События уже есть: день-функция отдаёт по паре (зерно, D) упорядоченный поток
|
События уже есть: день-функция отдаёт по паре (зерно, D) упорядоченный поток
|
||||||
pageview. Торговые события и запуск снаружи — за следующими тикетами.
|
трёх видов — просмотр страницы, корзина, покупка. Клиентская сторона на этом
|
||||||
|
целая; заказы бэкенда и запуск снаружи — за следующими тикетами.
|
||||||
|
|
||||||
## Как это работает
|
## Как это работает
|
||||||
|
|
||||||
@@ -53,7 +54,12 @@ D0 живёт предыстория, поэтому любой день соб
|
|||||||
`data/catalog/products.csv`, общего у генератора и словаря ClickHouse.
|
`data/catalog/products.csv`, общего у генератора и словаря ClickHouse.
|
||||||
- `src/clickstream_generator/day.py` — день-функция: визиты, страницы,
|
- `src/clickstream_generator/day.py` — день-функция: визиты, страницы,
|
||||||
атрибуция, устройство и гео. Там же правила резки визитов и шов, на
|
атрибуция, устройство и гео. Там же правила резки визитов и шов, на
|
||||||
который сядут торговые события.
|
который садятся торговые события.
|
||||||
|
- `src/clickstream_generator/commerce.py` — торговые события: что легло в
|
||||||
|
корзину, что из этого куплено, деньги, номера заказов и сырой `ecommerce`.
|
||||||
|
Своя случайность, поэтому правка торговли трафик не двигает.
|
||||||
|
- `src/clickstream_generator/ids.py` — номера событий: неповторяющиеся и
|
||||||
|
ниже 2^53. Обещание одно на обе половины дня, поэтому и живёт отдельно.
|
||||||
- `src/clickstream_generator/schema.py` — контракт схемы: чистые данные о
|
- `src/clickstream_generator/schema.py` — контракт схемы: чистые данные о
|
||||||
колонках выгрузки. Собственность генератора; из него выводятся сам
|
колонках выгрузки. Собственность генератора; из него выводятся сам
|
||||||
генератор, его валидация и описание выгрузки в доках.
|
генератор, его валидация и описание выгрузки в доках.
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ readme = "README.md"
|
|||||||
# Верхняя граница — не педантизм: обещание побайтовой воспроизводимости
|
# Верхняя граница — не педантизм: обещание побайтовой воспроизводимости
|
||||||
# держится на зафиксированных версиях (спека генератора, раздел 2).
|
# держится на зафиксированных версиях (спека генератора, раздел 2).
|
||||||
requires-python = ">=3.14,<3.15"
|
requires-python = ">=3.14,<3.15"
|
||||||
dependencies = ["numpy>=2"]
|
dependencies = [
|
||||||
|
"numpy>=2",
|
||||||
|
"orjson>=3.11.9",
|
||||||
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
|
|||||||
@@ -12,6 +12,10 @@
|
|||||||
Что решено формой файла, а не его длиной: колонки `sku,name,category,brand,
|
Что решено формой файла, а не его длиной: колонки `sku,name,category,brand,
|
||||||
price`; артикул — четыре латинские буквы категории, дефис и четыре цифры;
|
price`; артикул — четыре латинские буквы категории, дефис и четыре цифры;
|
||||||
цена — целые копейки (деньги генератор считает целыми, спека, раздел 2).
|
цена — целые копейки (деньги генератор считает целыми, спека, раздел 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
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
"""День-функция: (зерно, D) → упорядоченный поток событий модельных суток.
|
"""День-функция: (зерно, D) → упорядоченный поток событий модельных суток.
|
||||||
|
|
||||||
Здесь трафиковая половина мира: визиты, страницы, атрибуция, устройство и
|
Здесь трафиковая половина мира: визиты, страницы, атрибуция, устройство и
|
||||||
гео. Торговые события (#40) сядут на этот же поток и добавят к нему свои
|
гео. Торговые события садятся на этот же поток и добавляют к нему свои
|
||||||
строки; их колонки в pageview присутствуют, но пусты по смыслу — «пусто»
|
строки — их собирает `commerce` и им же поток заканчивается. У просмотра
|
||||||
всегда пустой массив, пустая строка или 0, а не отсутствие ключа.
|
страницы торговые колонки присутствуют, но пусты по смыслу: «пусто» — всегда
|
||||||
|
пустой массив, пустая строка или 0, а не отсутствие ключа.
|
||||||
|
|
||||||
День — чистая функция зерна и номера дня: одна и та же пара даёт те же
|
День — чистая функция зерна и номера дня: одна и та же пара даёт те же
|
||||||
события, а день N+1 не трогает дни 1…N. Держится это на подпотоке
|
события, а день N+1 не трогает дни 1…N. Держится это на подпотоке
|
||||||
@@ -25,23 +26,25 @@
|
|||||||
|
|
||||||
1. Визит принадлежит одной куке: склейка `ClientID` визитом не считается.
|
1. Визит принадлежит одной куке: склейка `ClientID` визитом не считается.
|
||||||
2. Пауза дольше 30 минут рвёт визит надвое, поэтому паузы внутри визита
|
2. Пауза дольше 30 минут рвёт визит надвое, поэтому паузы внутри визита
|
||||||
всегда короче таймаута, а соседние визиты куки разведены дальше него.
|
всегда короче таймаута, а соседние визиты куки разведены дальше него —
|
||||||
|
считая от последнего события визита, которым бывает покупка, а не от
|
||||||
|
последней его страницы.
|
||||||
3. Граница модельных суток режет визит: события за полночь в дне не живут.
|
3. Граница модельных суток режет визит: события за полночь в дне не живут.
|
||||||
Исключение одно — визит с заказом, обещанным планом двухкуковых пар: его
|
Исключение одно — визит с заказом, обещанным планом двухкуковых пар: его
|
||||||
старт сдвигается назад, чтобы воронка уместилась в сутки. Это принятое
|
старт сдвигается назад, чтобы воронка уместилась в сутки вместе с
|
||||||
ограничение модели: обещание плана — гарантия, ради неё мы сужаем свободу
|
торговым хвостом. Это принятое ограничение модели: обещание плана —
|
||||||
старта. Цена названа — около 24 визитов в день из ~9,5 тыс. не начинаются
|
гарантия, ради неё мы сужаем свободу старта. Цена названа — около 24
|
||||||
в последние минуты суток.
|
визитов в день из ~9,5 тыс. не начинаются в последние минуты суток.
|
||||||
|
|
||||||
**Шов для торговых событий (#40).** `Day` отдаёт, кроме колонок, два
|
**Шов с торговыми событиями.** Поток несёт, кроме колонок, два выровненных
|
||||||
выровненных по строкам ряда: `page` — какая это страница магазина, и
|
по строкам ряда: `page` — какая это страница магазина, и `product` — какой
|
||||||
`product` — какой товар показывала карточка (−1 у прочих страниц). По ним
|
товар показывала карточка (−1 у прочих страниц). По ним `commerce` знает и
|
||||||
#40 узнаёт и то, куда вешать событие (корзина, оформление, подтверждение),
|
то, куда сажать событие (карточка, подтверждение), и то, что посетитель на
|
||||||
и то, что посетитель на самом деле смотрел: товар в корзине, которого никто
|
самом деле смотрел: товар в корзине, которого никто не открывал, — видимая
|
||||||
не открывал, — видимая глупость в воронке. Визит с назначенным заказом
|
глупость в воронке. Визит с назначенным заказом всегда доходит до
|
||||||
всегда доходит до `/confirmation`, а перед корзиной у него всегда есть
|
`/confirmation`, а перед корзиной у него всегда есть карточка товара.
|
||||||
карточка товара. Своей случайности #40 не занимает: подпоток `COMMERCE`
|
Случайность у половин разная: трафик берёт подпоток `TRAFFIC`, торговля —
|
||||||
нетронут.
|
`COMMERCE`, и правка одной не сдвигает другую.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -50,21 +53,24 @@ from typing import Any
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
from numpy.typing import NDArray
|
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.reference import Page
|
||||||
from clickstream_generator.seeds import Component, day_stream
|
from clickstream_generator.seeds import Component, day_stream
|
||||||
from clickstream_generator.weights import pick, pick_row
|
from clickstream_generator.weights import pick, pick_row
|
||||||
|
|
||||||
DAY_SECONDS = 24 * 60 * 60
|
DAY_SECONDS = 24 * 60 * 60
|
||||||
|
|
||||||
# Потолок идентификаторов тот же, что у кук: выше 2^53 числа в JSON
|
|
||||||
# округляются (контракт схемы, `WatchID`).
|
|
||||||
ID_LIMIT = plan.CLIENT_ID_LIMIT
|
|
||||||
|
|
||||||
# Карточка перед корзиной обязательна: положить в корзину то, чего не
|
# Карточка перед корзиной обязательна: положить в корзину то, чего не
|
||||||
# открывал, посетитель не может.
|
# открывал, посетитель не может.
|
||||||
MIN_PAGES_BEFORE_CART = 2
|
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_COUNT_CUMULATIVE = np.cumsum(world.VISITS_PER_ACTIVE_DAY_WEIGHTS)
|
||||||
_VISIT_PAGES_CUMULATIVE = np.cumsum(world.VISIT_PAGES_WEIGHTS)
|
_VISIT_PAGES_CUMULATIVE = np.cumsum(world.VISIT_PAGES_WEIGHTS)
|
||||||
_SOURCE_CUMULATIVE = np.cumsum([source.weight for source in reference.TRAFFIC_SOURCES])
|
_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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class Day:
|
class Day:
|
||||||
"""Поток событий одного дня: колонки выгрузки и шов для торговых событий.
|
"""Поток событий одного дня: колонки выгрузки и страницы за ними.
|
||||||
|
|
||||||
Строки упорядочены по времени — так их и проиграет проигрыватель.
|
Строки упорядочены по времени — так их и проиграет проигрыватель.
|
||||||
`columns` — колонки контракта схемы по его порядку, все до одной;
|
`columns` — колонки контракта схемы по его порядку, все до одной;
|
||||||
@@ -124,9 +130,15 @@ def stream(seed: int, day: int) -> Day:
|
|||||||
|
|
||||||
second = np.repeat(start, visits.pages) + elapsed
|
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)
|
rest = _columns(rng, day, audience, visits, page, product, second)
|
||||||
columns = {
|
columns = {
|
||||||
@@ -136,12 +148,16 @@ def stream(seed: int, day: int) -> Day:
|
|||||||
}
|
}
|
||||||
|
|
||||||
order = np.lexsort((columns["WatchID"], columns["UTCEventTime"]))
|
order = np.lexsort((columns["WatchID"], columns["UTCEventTime"]))
|
||||||
return Day(
|
# Торговые события садятся на готовый трафиковый поток и отдают его
|
||||||
day=day,
|
# целиком: в нём же они и упорядочиваются.
|
||||||
columns={name: value[order] for name, value in columns.items()},
|
columns, page, product = commerce.weave(
|
||||||
page=page[alive][order],
|
seed,
|
||||||
product=product[alive][order],
|
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:
|
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)
|
ordering = audience.assigned_order[cookie] & (ordinal == 0)
|
||||||
stage = _funnel(rng, visits, ordering)
|
stage = _funnel(rng, audience.buyer[cookie], ordering)
|
||||||
# Воронка удлиняет визит, а не съедает его: до корзины надо ещё дойти.
|
# Воронка удлиняет визит, а не съедает его: до корзины надо ещё дойти.
|
||||||
browse = np.where(
|
browse = np.where(
|
||||||
stage > 0, np.maximum(length - stage, MIN_PAGES_BEFORE_CART), length
|
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(
|
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]:
|
) -> NDArray[np.int64]:
|
||||||
"""Докуда дошёл визит: 0 — до корзины не дошёл, 3 — до подтверждения."""
|
"""Докуда дошёл визит: 0 — до корзины не дошёл, 3 — до подтверждения.
|
||||||
draw = rng.integers(0, 100, (3, visits))
|
|
||||||
cart = draw[0] < world.CART_PERCENT
|
Помеченный планом покупатель отличается на обоих шагах: и до корзины
|
||||||
checkout = cart & (draw[1] < world.CHECKOUT_OF_CART_PERCENT)
|
доходит чаще, и бросает её реже. Склонность покупать — свойство
|
||||||
|
человека, а не визита, поэтому одинаковый для всех бросок оставил бы
|
||||||
|
метку плана словом без следа в данных (спека генератора, раздел 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)
|
confirmation = checkout & (draw[2] < world.CONFIRMATION_OF_CHECKOUT_PERCENT)
|
||||||
|
|
||||||
stage = cart.astype(np.int64) + checkout + confirmation
|
stage = cart.astype(np.int64) + checkout + confirmation
|
||||||
# Заказ, обещанный планом, воронку проходит целиком: гарантия пар стоит
|
# Заказ, обещанный планом, воронку проходит целиком: гарантия пар стоит
|
||||||
# на том, что событие покупки в этот день случится (#40 его и повесит).
|
# на том, что событие покупки в этот день случится: его повесит `commerce`.
|
||||||
stage[ordering] = len(reference.FUNNEL_PAGES)
|
stage[ordering] = len(reference.FUNNEL_PAGES)
|
||||||
return stage
|
return stage
|
||||||
|
|
||||||
@@ -219,7 +250,7 @@ def _walk(
|
|||||||
page[row] = table[step[row]]
|
page[row] = table[step[row]]
|
||||||
stage = int(visits.stage[visit])
|
stage = int(visits.stage[visit])
|
||||||
if stage:
|
if stage:
|
||||||
# В корзину — только с карточки: иначе #40 положит туда товар,
|
# В корзину — только с карточки: иначе в ней окажется товар,
|
||||||
# которого посетитель не открывал.
|
# которого посетитель не открывал.
|
||||||
page[begin + browsed - 1] = Page.PRODUCT
|
page[begin + browsed - 1] = Page.PRODUCT
|
||||||
page[begin + browsed : begin + browsed + stage] = funnel[:stage]
|
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])
|
hour = pick_row(rng, _hour_cumulative(day), audience.city[visits.cookie])
|
||||||
start = hour * 3600 + rng.integers(0, 3600, hour.size)
|
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)
|
start = np.where(visits.ordering, fits, start)
|
||||||
|
|
||||||
# Визиты куки идут по возрастанию времени и разведены дальше таймаута —
|
# Визиты куки идут по возрастанию времени и разведены дальше таймаута —
|
||||||
# иначе лаба склеила бы два визита в один и разошлась бы с `VisitID`.
|
# иначе лаба склеила бы два визита в один и разошлась бы с `VisitID`.
|
||||||
|
# Разводятся они от последнего события визита, а им бывает покупка:
|
||||||
|
# считать от последней страницы значило бы отдать таймауту торговый хвост.
|
||||||
start = start[np.lexsort((start, visits.cookie))]
|
start = start[np.lexsort((start, visits.cookie))]
|
||||||
for repeat in range(1, len(world.VISITS_PER_ACTIVE_DAY_WEIGHTS)):
|
for repeat in range(1, len(world.VISITS_PER_ACTIVE_DAY_WEIGHTS)):
|
||||||
later = np.flatnonzero(visits.ordinal == repeat)
|
later = np.flatnonzero(visits.ordinal == repeat)
|
||||||
earlier = later - 1
|
earlier = later - 1
|
||||||
start[later] = np.maximum(
|
start[later] = np.maximum(
|
||||||
start[later],
|
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
|
return start
|
||||||
|
|
||||||
@@ -370,7 +410,8 @@ def _columns(
|
|||||||
"RegionCity": by_city("name"),
|
"RegionCity": by_city("name"),
|
||||||
"RegionCountryID": np.full(total, reference.COUNTRY_REGION_ID, dtype=np.uint32),
|
"RegionCountryID": np.full(total, reference.COUNTRY_REGION_ID, dtype=np.uint32),
|
||||||
"RegionCityID": by_city("region_id", np.uint32),
|
"RegionCityID": by_city("region_id", np.uint32),
|
||||||
# Цели дублируют торговые события, поэтому их ставит #40.
|
# Цели дублируют торговые события, поэтому их ставит `commerce`:
|
||||||
|
# у просмотра страницы достигнутых целей нет.
|
||||||
"GoalsReached": _blank(total, "uint32"),
|
"GoalsReached": _blank(total, "uint32"),
|
||||||
# Своих параметров сайт стенда пока не шлёт: вариант A/B-теста был бы
|
# Своих параметров сайт стенда пока не шлёт: вариант 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]:
|
def _hour_cumulative(day: int) -> NDArray[np.int64]:
|
||||||
"""Веса часов суток по городам, накопленные: строка города — его волна."""
|
"""Веса часов суток по городам, накопленные: строка города — его волна."""
|
||||||
# Суббота и воскресенье — последние два дня недели, а D0 — понедельник.
|
# Суббота и воскресенье — последние два дня недели, а 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
|
||||||
@@ -9,7 +9,9 @@
|
|||||||
|
|
||||||
Что план решает до генерации событий и чем связывает дни между собой:
|
Что план решает до генерации событий и чем связывает дни между собой:
|
||||||
|
|
||||||
- приток — кто и когда впервые появился, и сколько раз вернётся;
|
- приток — кто и когда впервые появился, и сколько раз вернётся; кука
|
||||||
|
помеченного покупателя живёт дольше прочих — это один из двух рычагов
|
||||||
|
метки, второй лежит в воронке дня;
|
||||||
- двухкуковые пары — какой человек завёл вторую куку и в какие дни
|
- двухкуковые пары — какой человек завёл вторую куку и в какие дни
|
||||||
каждая из двух кук обязана оформить заказ;
|
каждая из двух кук обязана оформить заказ;
|
||||||
- паспорт куки — устройство и город: они у куки одни и те же во всех её
|
- паспорт куки — устройство и город: они у куки одни и те же во всех её
|
||||||
@@ -32,17 +34,14 @@ from functools import lru_cache
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
from numpy.typing import NDArray
|
from numpy.typing import NDArray
|
||||||
|
|
||||||
from clickstream_generator import reference, world
|
from clickstream_generator import ids, reference, world
|
||||||
from clickstream_generator.seeds import cohort_stream
|
from clickstream_generator.seeds import cohort_stream
|
||||||
from clickstream_generator.weights import pick
|
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)
|
_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)
|
_RETURN_DELAY_CUMULATIVE = np.cumsum(world.RETURN_DELAY_WEIGHTS)
|
||||||
_CITY_CUMULATIVE = np.cumsum([city.weight for city in reference.CITIES])
|
_CITY_CUMULATIVE = np.cumsum([city.weight for city in reference.CITIES])
|
||||||
_DEVICE_CUMULATIVE = np.cumsum(
|
_DEVICE_CUMULATIVE = np.cumsum(
|
||||||
@@ -182,15 +181,19 @@ def cohort(seed: int, day: int) -> Cohort:
|
|||||||
]
|
]
|
||||||
|
|
||||||
cookies = people + paired.size
|
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 = np.full(cookies, day, dtype=np.int64)
|
||||||
# Вторая кука рождается, пока человек ещё ходит: тем же затухающим
|
# Вторая кука рождается, пока человек ещё ходит: тем же затухающим
|
||||||
# профилем, что и возвраты, — обычно через дни, изредка через месяцы.
|
# профилем, что и возвраты, — обычно через дни, изредка через месяцы.
|
||||||
# Фиксированного зазора нет, иначе пары в данных узнавались бы по нему.
|
# Фиксированного зазора нет, иначе пары в данных узнавались бы по нему.
|
||||||
birth_day[people:] += 1 + pick(rng, _RETURN_DELAY_CUMULATIVE, paired.size)
|
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(
|
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)))
|
twins = np.column_stack((paired, np.arange(people, cookies, dtype=np.int64)))
|
||||||
pair_cookies, pair_order_days = _assign_orders(
|
pair_cookies, pair_order_days = _assign_orders(
|
||||||
@@ -204,8 +207,7 @@ def cohort(seed: int, day: int) -> Cohort:
|
|||||||
people=people,
|
people=people,
|
||||||
client_id=client_id,
|
client_id=client_id,
|
||||||
birth_day=birth_day,
|
birth_day=birth_day,
|
||||||
# Вторая кука принадлежит покупателю — как и первая кука его пары.
|
buyer=buyer_cookie,
|
||||||
buyer=np.concatenate((buyer, np.ones(paired.size, dtype=bool))),
|
|
||||||
active_cookie=active_cookie,
|
active_cookie=active_cookie,
|
||||||
active_day=active_day,
|
active_day=active_day,
|
||||||
pair_cookies=pair_cookies,
|
pair_cookies=pair_cookies,
|
||||||
@@ -271,13 +273,30 @@ def _influx(rng: np.random.Generator, day: int) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _active_days(
|
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]]:
|
) -> tuple[NDArray[np.int64], NDArray[np.int64]]:
|
||||||
"""Дни активности каждой куки: день рождения и возвраты, пока окно открыто."""
|
"""Дни активности каждой куки: день рождения и возвраты, пока окно открыто.
|
||||||
|
|
||||||
|
Помеченный планом покупатель живёт дольше прочих: одноразовым бывает
|
||||||
|
много реже и возвращается чаще. Это первый из двух рычагов метки
|
||||||
|
(второй — воронка дня): метки в событии нет, поэтому «постоянный
|
||||||
|
покупатель» читается в данных только как кука, которая ходит неделями и
|
||||||
|
покупает не раз. Одним лифтом конверсии этого не добиться — кука живёт
|
||||||
|
меньше двух визитов за снимок, и второй покупке негде случиться (спека
|
||||||
|
генератора, раздел 9).
|
||||||
|
"""
|
||||||
cookies = birth_day.size
|
cookies = birth_day.size
|
||||||
returns = np.zeros(cookies, dtype=np.int64)
|
returns = np.zeros(cookies, dtype=np.int64)
|
||||||
returning = rng.integers(0, 100, cookies) >= world.ONE_SHOT_PERCENT
|
one_shot = np.where(buyer, world.BUYER_ONE_SHOT_PERCENT, world.ONE_SHOT_PERCENT)
|
||||||
returns[returning] = 1 + pick(rng, _RETURN_COUNT_CUMULATIVE, int(returning.sum()))
|
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)
|
owner = np.repeat(np.arange(cookies, dtype=np.int64), returns)
|
||||||
delay = 1 + pick(rng, _RETURN_DELAY_CUMULATIVE, owner.size)
|
delay = 1 + pick(rng, _RETURN_DELAY_CUMULATIVE, owner.size)
|
||||||
|
|||||||
@@ -77,6 +77,19 @@ SEARCH_QUERIES = tuple(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Вариант товара — цвет или исполнение карточки. Живёт только в сыром
|
||||||
|
# `ecommerce`: в плоских массивах события такой колонки нет, и это одна из
|
||||||
|
# причин, по которым лаба «сырое против разобранного» вообще имеет смысл.
|
||||||
|
PRODUCT_VARIANTS = (
|
||||||
|
"стандарт",
|
||||||
|
"белый",
|
||||||
|
"серый",
|
||||||
|
"бежевый",
|
||||||
|
"синий",
|
||||||
|
"зелёный",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Коды типа устройства у Метрики: 1 — десктоп, 2 — телефон, 3 — планшет,
|
# Коды типа устройства у Метрики: 1 — десктоп, 2 — телефон, 3 — планшет,
|
||||||
# 4 — телевизор. Телефон назван отдельно: по нему различаются и мобильный
|
# 4 — телевизор. Телефон назван отдельно: по нему различаются и мобильный
|
||||||
# адрес, и вторая кука пары — «телефон и ноутбук» (мастер-спека, раздел 5).
|
# адрес, и вторая кука пары — «телефон и ноутбук» (мастер-спека, раздел 5).
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ PREAMBLE = """# Описание выгрузки: событие кликстр
|
|||||||
пересобрать: `make docs`.
|
пересобрать: `make docs`.
|
||||||
|
|
||||||
Одно событие — одна строка: хит по образцу облачной выгрузки Яндекс Метрики.
|
Одно событие — одна строка: хит по образцу облачной выгрузки Яндекс Метрики.
|
||||||
Многозначное лежит в параллельных массивах одной длины, плюс одно сырое
|
Многозначное лежит в параллельных массивах, плюс одно сырое JSON-поле
|
||||||
JSON-поле `ecommerce`. Отдельной сущности «визит» в выгрузке нет — визиты
|
`ecommerce`. Длина у массивов общая **внутри группы**, а не по всему
|
||||||
|
событию: `purchase*` — по элементу на заказ (у нас всегда один), `product*` —
|
||||||
|
по элементу на товар. Отдельной сущности «визит» в выгрузке нет — визиты
|
||||||
собирают на стороне хранилища, а `VisitID` дан как эталон для самопроверки.
|
собирают на стороне хранилища, а `VisitID` дан как эталон для самопроверки.
|
||||||
|
|
||||||
Имена и типы колонок — стороны источника. Хранилище принимает их как есть и
|
Имена и типы колонок — стороны источника. Хранилище принимает их как есть и
|
||||||
@@ -37,6 +39,10 @@ JSON-поле `ecommerce`. Отдельной сущности «визит» в
|
|||||||
`add_to_cart` несёт один товар, `purchase` — состав заказа и блок
|
`add_to_cart` несёт один товар, `purchase` — состав заказа и блок
|
||||||
`purchase*`. У остальных событий они пусты.
|
`purchase*`. У остальных событий они пусты.
|
||||||
|
|
||||||
|
Деньги: `productPrice` — целые рубли, округление формата. Точная сумма
|
||||||
|
заказа живёт в `purchaseRevenue` и в сыром `ecommerce`, поэтому пересчитать
|
||||||
|
выручку по разобранным массивам нельзя — цены каталога бывают с копейками.
|
||||||
|
|
||||||
Всего колонок: {count}."""
|
Всего колонок: {count}."""
|
||||||
|
|
||||||
TABLE_HEADER = (
|
TABLE_HEADER = (
|
||||||
|
|||||||
@@ -82,6 +82,15 @@ BUYER_PERCENT = 5
|
|||||||
# этом стоит лаба про склейку личности.
|
# этом стоит лаба про склейку личности.
|
||||||
PAIRED_BUYER_PERCENT = 15
|
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 часов
|
# Суточная волна буднего дня: проценты от среднего часа, от 00 до 23 часов
|
||||||
@@ -111,7 +120,7 @@ VISITS_PER_ACTIVE_DAY_WEIGHTS = (70, 22, 8)
|
|||||||
# Длина визита в страницах: веса для 1, 2, 3 … страниц. Первая доля — отказы
|
# Длина визита в страницах: веса для 1, 2, 3 … страниц. Первая доля — отказы
|
||||||
# (посмотрел одну страницу и ушёл), дальше затухающий хвост. В среднем ≈4,8
|
# (посмотрел одну страницу и ушёл), дальше затухающий хвост. В среднем ≈4,8
|
||||||
# страницы: вместе с числом визитов это ~45 тыс. pageview в средний день,
|
# страницы: вместе с числом визитов это ~45 тыс. pageview в средний день,
|
||||||
# и до ~50 тыс. добирают торговые события (#40).
|
# и до ~50 тыс. добирают торговые события.
|
||||||
VISIT_PAGES_WEIGHTS = (250, 150, 120, 100, 88, 78, 68, 58, 50, 42, 35, 28, 22, 16)
|
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
|
LONG_PAUSE_PERCENT = 12
|
||||||
|
|
||||||
# Воронка: доля визитов, дошедших до корзины, и доли следующих шагов от
|
# Воронка: доля визитов, дошедших до корзины, и доли следующих шагов от
|
||||||
# предыдущего. Произведение — конверсия визита в оформленный заказ: 2%
|
# предыдущего. Произведение — конверсия визита в оформленный заказ
|
||||||
# (спека генератора, раздел 9). Гарантированные планом заказы двухкуковых
|
# (спека генератора, раздел 9). Гарантированные планом заказы двухкуковых
|
||||||
# пар проходят воронку целиком независимо от этих долей.
|
# пар проходят воронку целиком независимо от этих долей.
|
||||||
CART_PERCENT = 8
|
CART_PERCENT = 6
|
||||||
CHECKOUT_OF_CART_PERCENT = 45
|
CHECKOUT_OF_CART_PERCENT = 45
|
||||||
CONFIRMATION_OF_CHECKOUT_PERCENT = 55
|
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
|
||||||
|
|||||||
@@ -54,6 +54,18 @@ def test_every_row_is_filled_and_priced_in_whole_kopecks():
|
|||||||
assert low <= int(row["price"]) <= high, row["sku"]
|
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():
|
def test_every_category_of_the_assortment_is_covered():
|
||||||
"""Каталог покрывает ассортимент целиком: пустых категорий не бывает."""
|
"""Каталог покрывает ассортимент целиком: пустых категорий не бывает."""
|
||||||
present = {row["category"] for row in rows()}
|
present = {row["category"] for row in rows()}
|
||||||
|
|||||||
@@ -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
@@ -1,4 +1,4 @@
|
|||||||
"""День-функция: чистота, форма волны, правила резки визитов, шов для #40.
|
"""День-функция: чистота, форма волны, правила резки визитов, шов с торговлей.
|
||||||
|
|
||||||
Числа мира тесты сторожат вилками спеки, а не точными значениями: менти
|
Числа мира тесты сторожат вилками спеки, а не точными значениями: менти
|
||||||
крутит конфигурацию, и падать тесты должны там, где сдвинулся вывод («средний
|
крутит конфигурацию, и падать тесты должны там, где сдвинулся вывод («средний
|
||||||
@@ -14,7 +14,16 @@ import numpy as np
|
|||||||
import pytest
|
import pytest
|
||||||
from numpy.typing import NDArray
|
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.reference import Page
|
||||||
from clickstream_generator.seeds import CANONICAL_SEED
|
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
|
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
|
column
|
||||||
for column in schema.COLUMNS
|
for column in schema.COLUMNS
|
||||||
if column.group in (schema.ColumnGroup.ECOMMERCE, schema.ColumnGroup.PARAMS)
|
if column.group in (schema.ColumnGroup.ECOMMERCE, schema.ColumnGroup.PARAMS)
|
||||||
]
|
]
|
||||||
assert len(waiting) > 10
|
assert len(trade) > 10
|
||||||
for column in waiting:
|
for column in trade:
|
||||||
cells = weekday.columns[column.name][:1000]
|
cells = weekday.columns[column.name][pageview][:1000]
|
||||||
if column.clickhouse_type.startswith("Array("):
|
if column.clickhouse_type.startswith("Array("):
|
||||||
assert all(cell.size == 0 for cell in cells), column.name
|
assert all(cell.size == 0 for cell in cells), column.name
|
||||||
else:
|
else:
|
||||||
assert set(cells) == {""}, column.name
|
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):
|
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):
|
def test_identifiers_survive_json(weekday: day.Day):
|
||||||
"""Числа выше 2^53 в JSON округляются — идентификаторам столько не нужно."""
|
"""Числа выше 2^53 в JSON округляются — идентификаторам столько не нужно."""
|
||||||
for name in ("WatchID", "VisitID", "ClientID"):
|
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
|
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):
|
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
|
assert 30_000 < len(weekday) < 70_000
|
||||||
visits = len(set(weekday.columns["VisitID"].tolist()))
|
visits = len(set(weekday.columns["VisitID"].tolist()))
|
||||||
assert 6_000 < visits < 14_000
|
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):
|
def test_the_funnel_converts_about_two_percent_of_visits(weekday: day.Day):
|
||||||
visits = len(set(weekday.columns["VisitID"].tolist()))
|
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
|
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):
|
def test_the_cart_always_follows_a_product_card(weekday: day.Day):
|
||||||
"""Шов для #40: товар в корзине посетитель до того открывал."""
|
"""Шов с торговлей: товар в корзине посетитель до того открывал."""
|
||||||
order = np.lexsort((local_seconds(weekday), weekday.columns["VisitID"]))
|
order = np.lexsort((local_seconds(weekday), weekday.columns["VisitID"]))
|
||||||
by_visit = weekday.page[order]
|
by_visit = weekday.page[order]
|
||||||
carts = np.flatnonzero(by_visit == Page.CART)
|
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):
|
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
|
inside = np.flatnonzero(visit[1:] == visit[:-1]) + 1
|
||||||
assert np.all(referer[inside] == url[inside - 1])
|
assert np.all(referer[inside] == url[inside - 1])
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -15,6 +15,13 @@ def mean_by_weights(values: tuple[int, ...], weights: tuple[int, ...]) -> float:
|
|||||||
return weighted / sum(weights)
|
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():
|
def test_origin_is_a_monday():
|
||||||
"""День недели считается как остаток номера дня — это верно от понедельника."""
|
"""День недели считается как остаток номера дня — это верно от понедельника."""
|
||||||
assert world.ORIGIN.weekday() == 0
|
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():
|
def test_average_cookie_is_active_about_two_days():
|
||||||
"""Сходимость с разделом 5: ≈1,9 активного дня на куку — отсюда 6–8 тыс."""
|
"""Сходимость с разделом 5: ≈1,9 активного дня на куку — отсюда 6–8 тыс."""
|
||||||
counts = tuple(range(1, len(world.RETURN_COUNT_WEIGHTS) + 1))
|
assert 1.8 <= active_days(world.ONE_SHOT_PERCENT, world.RETURN_COUNT_WEIGHTS) <= 2.0
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def test_return_delays_cover_the_whole_activity_window():
|
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
|
assert max(world.LONG_PAUSE_SECONDS) < world.VISIT_TIMEOUT_SECONDS
|
||||||
|
|
||||||
|
|
||||||
def test_the_funnel_converts_about_two_percent_of_visits():
|
def test_the_trade_event_never_overtakes_the_next_page():
|
||||||
"""Конверсия ~2% на сессию (спека, раздел 9) — произведение трёх шагов."""
|
"""Торговое событие отстаёт от своей страницы меньше, чем длится пауза.
|
||||||
conversion = (
|
|
||||||
world.CART_PERCENT
|
На этом стоят два свойства сразу: событие корзины не обгоняет страницу,
|
||||||
* world.CHECKOUT_OF_CART_PERCENT
|
на которой посетитель нажал кнопку, и не выходит за полночь — за ним в
|
||||||
* world.CONFIRMATION_OF_CHECKOUT_PERCENT
|
том же визите всегда идёт страница, которая полночь пережила.
|
||||||
/ 100**2
|
"""
|
||||||
)
|
assert max(world.TRADE_DELAY_SECONDS) <= min(world.PAGE_PAUSE_SECONDS)
|
||||||
assert 1.5 <= conversion <= 2.5
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
Generated
+28
-1
@@ -8,6 +8,7 @@ version = "0.1.0"
|
|||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "numpy" },
|
{ name = "numpy" },
|
||||||
|
{ name = "orjson" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
@@ -18,7 +19,10 @@ dev = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [{ name = "numpy", specifier = ">=2" }]
|
requires-dist = [
|
||||||
|
{ name = "numpy", specifier = ">=2" },
|
||||||
|
{ name = "orjson", specifier = ">=3.11.9" },
|
||||||
|
]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "packaging"
|
name = "packaging"
|
||||||
version = "26.2"
|
version = "26.2"
|
||||||
|
|||||||
Reference in New Issue
Block a user