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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-02 19:13:30 +03:00
co-authored by Claude Opus 5
parent bfbaa96696
commit 722dbe22b7
19 changed files with 1591 additions and 210 deletions
+8 -2
View File
@@ -5,8 +5,10 @@
пересобрать: `make docs`.
Одно событие — одна строка: хит по образцу облачной выгрузки Яндекс Метрики.
Многозначное лежит в параллельных массивах одной длины, плюс одно сырое
JSON-поле `ecommerce`. Отдельной сущности «визит» в выгрузке нет — визиты
Многозначное лежит в параллельных массивах, плюс одно сырое JSON-поле
`ecommerce`. Длина у массивов общая **внутри группы**, а не по всему
событию: `purchase*` — по элементу на заказ (у нас всегда один), `product*`
по элементу на товар. Отдельной сущности «визит» в выгрузке нет — визиты
собирают на стороне хранилища, а `VisitID` дан как эталон для самопроверки.
Имена и типы колонок — стороны источника. Хранилище принимает их как есть и
@@ -21,6 +23,10 @@ JSON-поле `ecommerce`. Отдельной сущности «визит» в
`add_to_cart` несёт один товар, `purchase` — состав заказа и блок
`purchase*`. У остальных событий они пусты.
Деньги: `productPrice` — целые рубли, округление формата. Точная сумма
заказа живёт в `purchaseRevenue` и в сыром `ecommerce`, поэтому пересчитать
выручку по разобранным массивам нельзя — цены каталога бывают с копейками.
Всего колонок: 47.
## Идентификаторы и время
+76 -20
View File
@@ -447,15 +447,18 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
- **D0 = 2026-06-01, понедельник** — решение владельца при нарезке
(2026-08-01). Дата недавняя, чтобы данные первые месяцы выглядели
свежими; привязки к реальному календарю у констант мира всё равно нет.
- **Измерено на собранном плане** (#38, канонический мир, 14 дней):
приток 3 828 кук в день в среднем, дневная аудитория 6 235–7 124 —
обе величины в вилках раздела 5. Накопленная аудитория за снимок —
68 тыс. кук: 53,6 тыс. новыми плюс 14,5 тыс. возвратами предыстории
(при нарезке возвраты предыстории оценили вдвое скромнее — отсюда
ходившая раньше оценка ≈60 тыс.). Пар, у которых оба назначенных
заказа попали внутрь 14 дней, — 170; остальные пары горизонт
- **Измерено на собранном плане** (канонический мир, 14 дней; числа
перемерены при исполнении #40 — рычаг долгой жизни помеченного
покупателя сдвинул состав): приток 3 828 кук в день в среднем, дневная
аудитория 6 637–7 595 — обе величины в вилках раздела 5. Накопленная
аудитория за снимок — 70,2 тыс. кук: 53,6 тыс. новыми плюс 16,6 тыс.
возвратами предыстории (при нарезке возвраты предыстории оценили
скромнее — отсюда ходившая раньше оценка ≈60 тыс.). Пар, у которых оба
назначенных заказа попали внутрь 14 дней, — 192; остальные пары горизонт
переживают, их вторая кука приходит позже. Цифры пересчитываются
прогоном плана, событий для них не нужно.
прогоном плана, событий для них не нужно. До правки #40 те же измерения
давали: аудитория 6 235–7 124, накопленная 68,1 тыс. (14,5 тыс.
возвратами предыстории), 170 пар.
- **Конфигурация мира — модуль чистых данных** рядом с контрактом схемы:
все числа мира в одном месте, написанном как приглашение любопытному
менти крутить. Правка модуля — смена мира: чек манифеста честно
@@ -538,10 +541,14 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
Правила лежат в докстринге `day.py`, и тест собирает визиты по ним
заново, сверяя сборку с `VisitID`.
- **Числа дня**: ≈1,4 визита на день активности куки и ≈4,8 страницы на
визит — 9,5 тыс. визитов и ~45 тыс. pageview в средний день; воронка
8% визитов до корзины → 45% из них до оформления → 55% из них до
подтверждения, то есть конверсия визита ~2%. Итог сложится после
торговых событий (#40) и ляжет в манифест.
визит. Воронка при исполнении #39 была общей для всех: 8% визитов до
корзины → 45% из них до оформления → 55% из них до подтверждения, то есть
конверсия визита ~2% при 9,5 тыс. визитов и ~45 тыс. pageview в средний
день. При исполнении #40 воронка развелась надвое (блок ниже): обычному
посетителю 6% до корзины и те же 45% и 55%, помеченному покупателю — 18%
и 60%. Перемеренный средний день: 9 879 визитов, 47 433 pageview, 4,80
страницы на визит, конверсия визита 2,4%; полные числа — в блоке #40,
оттуда же они лягут в манифест.
- **Недельная волна применяется один раз.** Профиль ведёт приток, трафик
наследует его через дневную аудиторию; измеренный размах трафика — ±7%
против ±10% у притока. Второе умножение удвоило бы недельный размах. Если
@@ -595,15 +602,24 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
воронке (#39); правятся оба. Отличается помеченный на обоих шагах воронки:
и до корзины доходит чаще, и бросает её реже. В жизни различаются оба — кто
пришёл смотреть, тот и кладёт реже, и до конца доводит реже; один шаг дал
бы половину картины. Числа под каждый шаг — калибровка. Границы правки —
вилки раздела 5: дневная
аудитория остаётся в пределах 6–8 тыс., средний день — около 50 тыс.
событий, конверсия визита — около 2%. Расти внутри вилки аудитории
бы половину картины. Числа под каждый шаг — калибровка: помеченному 18% до
корзины и 60% из корзины в оформление против 6% и 45% у обычного
посетителя. Обычного калибровка тоже задела — его доля до корзины опущена
с 8% до 6%, и это единственное число мира, изменённое вне двух рычагов.
Довод: помеченные живут дольше и занимают уже 12% дневной аудитории вместо
5% людей когорты, поэтому при прежних 8% конверсия мира ушла бы к 2,9% —
за вилку. Рычаг метки не должен оплачиваться ростом конверсии, и границы
правки — вилки раздела 5: дневная аудитория остаётся в пределах
6–8 тыс., средний день — около 50 тыс. событий, конверсия визита —
около 2%. Расти внутри вилки аудитории
разрешено: помеченные ходят чаще, и это по-прежнему правдоподобный магазин.
Измеренные числа состава (блок #38 выше) после правки перемеряются и
переписываются; база до правки — в средний день аудитория 6 710, визитов
9 249, событий 44 436, визитов с корзиной 780 (8,4%) и с покупкой 221
(2,4%). Уточнение к разделу 1: вольные покупки по-прежнему решает день, но
Измерено после правки (канонический мир, 14 дней): в средний день
аудитория 7 166, визитов 9 879, событий 49 509, визитов с корзиной 765
(7,7%) и с покупкой 240 (2,4%) — все три вилки выдержаны. База до правки
была: аудитория 6 710, визитов 9 249, событий 44 436, визитов с корзиной
780 (8,4%) и с покупкой 221 (2,4%). Числа состава мира (блок #38 выше)
перемерены тем же прогоном. Уточнение к разделу 1: вольные покупки
по-прежнему решает день, но
случайность в них больше не одинакова для всех. Отклонено: *оставить
монетку* — «покупатель» остаётся именем без следа в данных; *один лифт без
долгой жизни* — довод выше, повторных покупок он почти не прибавляет;
@@ -662,6 +678,46 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
ломал бы его ровно там, ради чего написан. Визит, у которого страница
подтверждения срезана полуночью (1,2% покупающих), покупки не даёт — заказа
не было.
- **Счётчика покупок в плане состава не заводится.** Тикет просил «счётчики
покупок и двухкуковых пар сходятся с планом», но покупок план не считает и
считать не будет: раздел 1 обещает до генерации только состав — посетителей,
приток, пары, — а торговые счётчики складываются из поведения дня. Квота на
покупки заставила бы день добирать до точного числа, и конверсия в данных
вышла бы подозрительно ровной — условность видна там, куда лаба смотрит.
Сходятся пары; покупки проверяются постфактум по сгенерированному дню и по
вилке конверсии (~2% на визит).
Решено при исполнении #40 самой реализацией:
- **Торговый хвост разводит и соседние визиты, а не только сутки.** Правило
резки требует, чтобы визиты одной куки стояли дальше таймаута; считать это
расстояние от последней страницы, когда последним событием визита бывает
покупка, значило отдать таймауту торговый хвост — лаба сессий склеила бы
два визита в один и разошлась бы с `VisitID`. Расстояние считается от
последнего события; поймано тестом сборки визитов.
- **Полночь забирает подтверждение вместе с его покупкой.** Резерв суток
сначала считался только для визитов с обещанным планом заказом, и у
вольной покупки оставалось окно в несколько секунд: страница
подтверждения дожила до потока, а событие покупки за полночь вышло. Это
была бы потеря на клиентской стороне — класс этапов 4 и 6, заведённый по
недосмотру. Теперь запас под торговый хвост есть у всякой страницы
подтверждения: либо она уходит за границу суток вместе с покупкой, либо
остаются обе. Это ровно то правило, которое блок выше называет словами
«визит, у которого страница подтверждения срезана полуночью, покупки не
даёт»; в каноническом мире числа от правки не сдвинулись — случай не
выпал ни разу за 14 дней.
- **Задержка торгового события короче самой короткой паузы между
страницами.** Тогда событие корзины не обгоняет страницу, на которой
посетитель нажал кнопку, и не выходит за полночь: за ним в том же визите
всегда идёт страница, которая полночь пережила.
- **Номера торговых событий — из торгового подпотока.** `WatchID` строки
корзины и покупки рисует `COMMERCE`: возьми их у трафика, и правка
торгового поведения сдвинула бы трафиковые номера. Ряды двух подпотоков
проверяются на непересечение — обещание уникальности держит дедупликацию
при переигровке дня, и предполагать его нельзя.
- **Вариант товара — справочник мира.** Сырой `ecommerce` несёт `variant`,
которого в плоских колонках нет; строки живут в справочниках рядом с
устройствами и городами, а не сочиняются на месте.
Остаётся открытым, за тикетами: