diff --git a/docs/specs/2026-07-30-stand-v2-realism.md b/docs/specs/2026-07-30-stand-v2-realism.md index 32c2d87..4ef87ef 100644 --- a/docs/specs/2026-07-30-stand-v2-realism.md +++ b/docs/specs/2026-07-30-stand-v2-realism.md @@ -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, вероятность в генераторе | ~1–2% | +| C | Дельта суммы | сверка приведена к сравнимой базе (`items_total`, не `total`); `amount_delta` — только необъяснённый остаток после этого, и создаёт его генератор намеренно: деньги считаются целыми копейками, поэтому Float64 сам по себе не плывёт | ~1–2% | | 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`, вне приоритетов расхождений). После diff --git a/docs/specs/2026-08-01-generator.md b/docs/specs/2026-08-01-generator.md index 138bada..3c1aa6c 100644 --- a/docs/specs/2026-08-01-generator.md +++ b/docs/specs/2026-08-01-generator.md @@ -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% покупающих), покупки не даёт — заказа + не было. Остаётся открытым, за тикетами: