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