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:
@@ -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, вероятность в генераторе | ~1–2% |
|
| C | Дельта суммы | сверка приведена к сравнимой базе (`items_total`, не `total`); `amount_delta` — только необъяснённый остаток после этого, и создаёт его генератор намеренно: деньги считаются целыми копейками, поэтому Float64 сам по себе не плывёт | ~1–2% |
|
||||||
| 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`, вне приоритетов расхождений). После
|
||||||
|
|||||||
@@ -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% покупающих), покупки не даёт — заказа
|
||||||
|
не было.
|
||||||
|
|
||||||
Остаётся открытым, за тикетами:
|
Остаётся открытым, за тикетами:
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user