docs(generator): решения #40 после холодного ревью

- Зачем:
  - холодное ревью нашло две дыры уровня решений: лифт конверсии не делает
    покупателя видимым в данных, а цены каталога кратны рублю — от этого
    урок про Float64 остаётся без материала.
- Что:
  - метка покупателя получает второй рычаг: помеченные дольше живут и чаще
    возвращаются; границы правки заданы вилками раздела 5, база до правок
    записана числами.
  - часть цен каталога получит копейки; хвост этапу 4 переписан честнее.
  - дописаны нерешённые места: количество штук и повторная карточка,
    множество нумерации заказов, таблица «код — скидка», состав сырого
    ecommerce, резерв времени вместо зажима к полуночи.
  - два расхождения внесены в мастер-спеку и записаны в раздел 7: длина
    массивов purchase* и product*, механика класса amount_delta.
- Проверка:
  - чтением: docs/specs/2026-08-01-generator.md, разделы 7-9;
    docs/specs/2026-07-30-stand-v2-realism.md, разделы 1, 4, 7.
This commit is contained in:
2026-08-02 17:37:48 +03:00
parent a7442765ff
commit 2cfda7180f
2 changed files with 104 additions and 58 deletions
+7 -4
View File
@@ -48,8 +48,10 @@
## 1. Широкое событие кликстрима
Форма — хит Метрики из облачной выгрузки: одно событие = одна строка,
многозначное — в параллельных массивах одной длины, плюс одно сырое
JSON-поле `ecommerce`. Сессий в потоке нет — их менти собирает сам в DDS.
многозначное — в параллельных массивах, плюс одно сырое JSON-поле
`ecommerce`. Одна длина у массивов общая внутри группы: `purchase*` — по
элементу на заказ, `product*` — по элементу на товар; между собой группы
разной длины. Сессий в потоке нет — их менти собирает сам в DDS.
### 1.1 Решения по именам и типам
@@ -239,7 +241,7 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
|---|---|---|---|
| A | Отмена | заказ дошёл до `cancelled`, `purchase` остался | ~5% заказов |
| B | Потерянное событие | заказ есть, `purchase` не доехал | ~3% |
| C | Дельта суммы | сверка приведена к сравнимой базе (`items_total`, не `total`); `amount_delta` — только необъяснённый остаток после этого: округления Float64, вероятность в генераторе | ~12% |
| C | Дельта суммы | сверка приведена к сравнимой базе (`items_total`, не `total`); `amount_delta` — только необъяснённый остаток после этого, и создаёт его генератор намеренно: деньги считаются целыми копейками, поэтому Float64 сам по себе не плывёт | ~12% |
| D | Дубль события | повторный `purchase` от обновления `/confirmation`: новый `WatchID` с тем же `purchaseID` — бизнес-дубль, не технический; дедуп ReplacingMergeTree его не съедает и не должен | ~2% |
Классы пересекаются — приоритет: `cancelled` > `lost_event` >
@@ -405,7 +407,8 @@ Kafka день переигрывается генератором заново:
(`match` / `cancelled` / `lost_event` / `duplicate_event` / `amount_delta`,
в порядке приоритета — классы пересекаются, побеждает более ранний).
`match` — большинство строк; `amount_delta` — только необъяснённый остаток
после приведения к сравнимой базе (округления Float64, ~1–2% заказов).
после приведения к сравнимой базе; его создаёт генератор намеренно
(~1–2% заказов, см. раздел 4).
Строка «`purchase` без заказа» внутри живого окна — опоздание, ждущее
слепка, а не расхождение: она получает служебный класс `awaiting_order`
(шестое значение `mismatch_class`, вне приоритетов расхождений). После
+97 -54
View File
@@ -388,15 +388,26 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
переобработки при исчерпании retention Kafka (раздел 7), формулировка
этапа 7 (раздел 9).
Внесены при исполнении #40 (2026-08-02):
- **Раздел 1**: «в параллельных массивах одной длины» уточнено — длина общая
внутри группы, а `purchase*` и `product*` между собой разной длины.
- **Разделы 4 и 7**: механика класса `amount_delta` названа честнее.
Округления `Float64` сами по себе расхождения не дают: деньги считаются
целыми копейками, и дробное число в событии одно. Класс создаёт генератор
намеренно — так мастер-спека и допускала вторым вариантом.
## 8. Хвосты следующим этапам
- **Этап 3 (заказы бэкенда)**: заказу, у которого в клиентском событии стоит
промокод, бэкенд обязан дать скидку — иначе данные соврут. Номер заказа обе
стороны берут один и тот же (раздел 9).
- **Этап 4 (сверка)**: расхождение сумм само не появится — цены каталога
кратны рублю, и `Float64` на таких числах не плывёт. Класс `amount_delta`
придётся создавать намеренно, вероятностью в генераторе, — мастер-спека
(раздел 4) это и допускает.
- **Этап 4 (сверка)**: копейки в ценах каталога дают разрыв внутри события —
`productPrice` округлён форматом, `purchaseRevenue` точна. В сверку он сам
не попадает: клиент шлёт точную сумму, и она сходится с `items_total`
бэкенда. Класс `amount_delta` этап 4 всё равно создаёт намеренно —
мастер-спека (раздел 4) это и допускает; материал для разговора про Float64
теперь есть.
- **Этап 5 (Airflow)**: живой день со стороны хранилища — обычный ETL-даг
по расписанию (~раз в 24 минуты); генератор не дорабатывается.
- **Этап 7 (эталонный мир)**: пересборка — это манифест, не артефакт;
@@ -549,72 +560,104 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
`/confirmation`, а перед корзиной у него всегда есть карточка. Своей
случайности #39 у #40 не занимает: подпоток `COMMERCE` не тронут.
Решено при исполнении #40 (2026-08-02) — решения владельца до реализации:
Решено при исполнении #40 (2026-08-02) — решения владельца до реализации.
Три решения выходят за границы тикета: правятся план состава (#38), воронка
(#39) и каталог товаров (#39). Сейчас это дёшево — манифест ещё не собран
(#42); позже обошлось бы его пересборкой. Часть решений принята после
холодного ревью первой редакции блока.
- **Корзина шире заказа.** Посетитель кладёт в корзину товары тех карточек,
которые открывал в этом визите, а покупает не всё: около 15% позиций
остаются брошенными. Иначе событие корзины не рассказывало бы ничего сверх
покупки — заказ был бы её точной копией, и сравнивать было бы нечего.
Замерено на каноническом мире до правки воронки (ниже): у покупающего
визита 2,1 карточки, в заказе
выходит 1,9 позиции, однопозиционных заказов около половины. Отклонено:
*заказ всегда из одного товара* — список позиций как урок умирает, а на нём
стоит единственный носитель навыка «вложенный JSON» (мастер-спека,
раздел 2); *досыпать в корзину товары, которых посетитель не открывал*
ломает шов #39 ровно там, ради чего он строился; *бросать много* (40% и
выше) — две трети заказов схлопываются в один товар.
- **Покупатель перестаёт быть словом.** Метка плана — «этот человек склонен
покупать», 5% людей когорты — до сих пор ни на что не влияла: кто купит,
решал одинаковый для всех бросок в дне. Свойство человека в данных не
читалось, постоянных покупателей в мире не было. Теперь метка правит
воронкой: помеченный доходит до заказа заметно чаще прочих, но и
непомеченный иногда покупает. Общее число покупок и общая доля визитов с
корзиной остаются прежними — меняется лишь то, кто эти визиты совершает;
точные доли — калибровка при реализации, требование сторожит тест.
Уточнение к разделу 1: вольные покупки по-прежнему решает день, но
случайность в них больше не одинакова для всех. Плата
названа: воронка живёт в коде #39, поэтому правка выходит за исходные
границы тикета и сдвигает трафиковый поток. Сейчас это ничего не стоит —
манифест ещё не собран (#42); позже обошлось бы его пересборкой.
Отклонено: *оставить как есть* — числа сходятся, но «покупатель» остаётся
именем без следа в данных; *покупают только помеченные* — 5% людей дают
около 40 заказов в день вместо 220; вернуть их можно, лишь подняв долю
покупателей до 10–30% людей, а с ней вырастет число двухкуковых пар и
измеренные числа #38 придётся пересчитывать.
Замерено на мире до правок: у покупающего визита 2,1 карточки, в заказе
выходит 1,9 позиции, однопозиционных заказов около половины. Карточка,
открытая в визите дважды, даёт одну позицию, а не две: повторный просмотр —
раздумье, а не второй товар. Количество штук — свой бросок: обычно одна,
изредка две-три. Отклонено: *заказ всегда из одного товара* — список
позиций как урок умирает, а на нём стоит единственный носитель навыка
«вложенный JSON» (мастер-спека, раздел 2); *досыпать в корзину товары,
которых посетитель не открывал* — ломает шов #39 ровно там, ради чего он
строился; *бросать много* (40% и выше) — две трети заказов схлопываются в
один товар.
- **Покупатель перестаёт быть словом — двумя рычагами сразу.** Метка плана
(«этот человек склонен покупать», 5% людей когорты) до сих пор ни на что не
влияла: кто купит, решал одинаковый для всех бросок в дне. Теперь
помеченный, во-первых, доходит до заказа заметно чаще прочих, во-вторых,
дольше живёт и чаще возвращается. Первый рычаг — про правдоподобие:
склонность покупать и вправду свойство человека, а не свойство визита.
Второй — про видимость: метки в событии нет и не будет (кликстрим
анонимен), поэтому «постоянный покупатель» читается в данных только как
кука, которая ходит неделями и покупает не раз. Одним лифтом это
недостижимо: кука живёт меньше двух визитов за снимок, и второй покупке
негде случиться — лифт поднял бы долю кук с двумя покупками с 2% до 6% и на
том исчерпался. Рычаг жизни лежит в плане (#38), рычаг конверсии — в
воронке (#39); правятся оба. Границы правки — вилки раздела 5: дневная
аудитория остаётся в пределах 6–8 тыс., средний день — около 50 тыс.
событий, конверсия визита — около 2%. Расти внутри вилки аудитории
разрешено: помеченные ходят чаще, и это по-прежнему правдоподобный магазин.
Измеренные числа состава (блок #38 выше) после правки перемеряются и
переписываются; база до правки — в средний день аудитория 6 710, визитов
9 249, событий 44 436, визитов с корзиной 780 (8,4%) и с покупкой 221
(2,4%). Уточнение к разделу 1: вольные покупки по-прежнему решает день, но
случайность в них больше не одинакова для всех. Отклонено: *оставить
монетку* — «покупатель» остаётся именем без следа в данных; *один лифт без
долгой жизни* — довод выше, повторных покупок он почти не прибавляет;
*покупают только помеченные* — 5% людей дают около 40 заказов в день вместо
220, вернуть их можно, лишь подняв долю покупателей до 10–30% людей, а с
ней вырастет и число двухкуковых пар.
- **Событие корзины садится на карточку товара**, событие покупки — на
страницу подтверждения. В жизни событие корзины шлётся нажатием кнопки на
карточке, а не открытием страницы корзины; при нескольких товарах в заказе
иначе его и не разложить — событий столько, со скольких карточек положили.
- **Номер заказа читаемый**: день модельного времени и порядковый номер
покупки в этом дне. Магазины так и нумеруют, и по номеру сразу видно день.
Он же — `order_id` бэкенда: обе стороны получают один номер, по нему
соединяется сверка (мастер-спека, раздел 4).
- **Промокод в событии есть, в сумме его нет.** Часть заказов уходит с
покупки в этом дне (`20260603-0042`). Магазины так и нумеруют, и по номеру
сразу видно день. Он же — `order_id` бэкенда: обе стороны получают один
номер, по нему соединяется сверка (мастер-спека, раздел 4). Нумеруются все
покупки, дошедшие до потока дня, в порядке событий — до всяких потерь.
Расхождения этапа 6 выбрасывают событие, когда номер уже присвоен: иначе
одна потеря перенумеровала бы чужие заказы, и мост к бэкенду разъехался бы.
- **Промокод в событии есть, скидки в сумме нет.** Часть заказов уходит с
промокодом, но выручка, которую шлёт клиент, — сумма позиций без скидки и
доставки. Так и в жизни: код на сайте знает корзину, а не итог расчёта. Это
ровно то расхождение с бэкендом, которое менти будет раскапывать
(мастер-спека, раздел 4). Отклонено: *промокода нет никогда* — колонка
формата осталась бы мёртвой.
- **Деньги: копейки внутри, рубли в колонках, дробь только в выручке.**
Генератор считает целыми копейками; цена товара в событии — целые рубли,
как у Метрики (`productPrice` там `Int64`); дробное число одно —
`purchaseRevenue` типа `Float64`. Деление на сто верно, лишь пока цены
каталога кратны рублю: это сторожит тест, иначе копейки утекали бы молча.
доставки. Так и в жизни: код на сайте знает корзину, а не итог расчёта.
Класса расхождения промокод не даёт: сверка приведена к `items_total`, где
скидки и нет (мастер-спека, раздел 7). Он объясняет разрыв между клиентской
суммой и итогом заказа — одну из причин, по которым «суммы не сойдутся»
(мастер-спека, раздел 4). Таблица «код → скидка» — число мира и живёт
здесь: этап 3 берёт её готовой, а не сочиняет заново. Отклонено:
*промокода нет никогда* — колонка формата осталась бы мёртвой.
- **Деньги: копейки внутри, рубли в колонках, дробь только в выручке — и
копейки в части цен каталога.** Генератор считает целыми копейками; цена
товара в событии — целые рубли, как у Метрики (`productPrice` там `Int64`);
дробное число одно — `purchaseRevenue` типа `Float64`. Все 180 цен каталога
кратны рублю, и это делало урок про Float64 беспредметным: округлять
нечего, копейки существуют только на словах, а расхождение сумм пришлось бы
выдумывать вероятностью. Поэтому часть цен получает копейки (1 299,90 ₽ —
обычная розница): `productPrice` округляется самим форматом, а
`purchaseRevenue` несёт точную сумму. Разрыв живёт внутри события — считать
выручку по разобранным массивам нельзя, и это настоящий урок формата, а не
придуманный.
- **Массивы `purchase*` и `product*` разной длины между собой.** Внутри своей
группы длина общая: `purchase*` — по элементу на заказ (у нас всегда один),
`product*` — по элементу на товар. Мастер-спека (раздел 1) говорит о
«параллельных массивах одной длины», и прочесть это как «все массивы
события одной длины» легко — поэтому в описании выгрузки сказано прямо.
- **Сырой `ecommerce` собирает канонический сериализатор.** Поле — строка
JSON внутри события, и собирать её руками значит писать второй
сериализатор со своим экранированием. Зависимость `orjson`, названная
рабочим выбором в разделе 4, появляется здесь, а не в #41.
- **Хвост покупки не выходит за полночь.** Событие покупки встаёт на
несколько секунд позже просмотра страницы подтверждения, а граница суток
режет всё, что за неё вышло. Визит с обещанным планом заказом уже подвинут
так, чтобы уместиться в сутки (#39), но подвинут по страницам — торгового
хвоста в том расчёте не было. Хвост прижимается к последней секунде суток;
иначе гарантия двухкуковых пар порвалась бы молча.
- **Сырой `ecommerce` несёт больше, чем плоские колонки.** Лаба «сырое против
разобранного» (мастер-спека, раздел 1.2) имеет смысл, только если в сыром
лежит то, чего в массивах нет: бренд товара, его вариант, блок
`actionField` целиком. Собирает строку канонический сериализатор — руками
это второй сериализатор со своим экранированием; зависимость `orjson`,
названная рабочим выбором в разделе 4, появляется здесь, а не в #41.
- **Торговый хвост умещается в сутки резервом, а не зажимом.** Событие
покупки встаёт на несколько секунд позже просмотра страницы подтверждения,
событие корзины — позже своей карточки, а граница суток режет всё, что за
неё вышло. Визит с обещанным планом заказом уже подвинут так, чтобы
уместиться в сутки (#39), но подвинут по страницам — торгового хвоста в том
расчёте не было. Резерв расширяется на хвост: тогда правило «покупка на
секунды позже подтверждения» держится всегда, а зажим к последней секунде
ломал бы его ровно там, ради чего написан. Визит, у которого страница
подтверждения срезана полуночью (1,2% покупающих), покупки не даёт — заказа
не было.
Остаётся открытым, за тикетами: