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