From 6f18e9989c85a1510fcdbc5ab5c912a67b40855e Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Fri, 28 Aug 2026 08:16:11 -0400 Subject: [PATCH] =?UTF-8?q?docs(generator):=20=D0=B7=D0=B0=D0=BC=D0=BE?= =?UTF-8?q?=D1=80=D0=BE=D0=B6=D0=B5=D0=BD=D0=B0=20=D1=81=D0=BF=D0=B5=D0=BA?= =?UTF-8?q?=D0=B0=20=D1=8D=D1=82=D0=B0=D0=BF=D0=B0=202,=20=D1=80=D0=B5?= =?UTF-8?q?=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20=E2=80=94=20=D0=B2=20ADR=20001?= =?UTF-8?q?5-0017?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - спека разрослась до 1058 строк, а журнал решений в разделе 9 читался только целиком; агентам и людям нужен дешевый адресный заход к основаниям решений генератора. - Что: - заведены ADR 0015 (пояс счетчика), 0016 (паспорт куки) и 0017 (номер заказа); четыре адресные ссылки на «раздел 9» перешиты на них. - раздел 9 пересобран в 11 тематических подразделов с датами и тикетами у каждого решения и закрыт пометкой о заморозке; устаревшие промежуточные замеры свернуты до указателей на актуальные. - строка статуса в шапке отмечает закрытие спеки как документа этапа 2. - Проверка: - wc -l docs/specs/2026-08-01-generator.md (951, было 1058); rg -n 'раздел[а-яё]* 9|разделы 8 и 9' docs -g '*.md' — 18 попаданий, все классифицированы; разделы 1-8 нетронуты (сверка хешем). Co-Authored-By: Claude Fable 5 --- docs/adr/0015-counter-timezone.md | 36 + docs/adr/0016-cookie-passport.md | 28 + docs/adr/0017-order-numbering.md | 34 + docs/architecture/generator/README.md | 4 + .../architecture/generator/cookie-passport.md | 6 +- docs/architecture/generator/counter-time.md | 4 +- docs/architecture/orders/snapshot.md | 7 +- docs/architecture/storage.md | 2 +- docs/specs/2026-08-01-generator.md | 927 ++++++++---------- 9 files changed, 522 insertions(+), 526 deletions(-) create mode 100644 docs/adr/0015-counter-timezone.md create mode 100644 docs/adr/0016-cookie-passport.md create mode 100644 docs/adr/0017-order-numbering.md diff --git a/docs/adr/0015-counter-timezone.md b/docs/adr/0015-counter-timezone.md new file mode 100644 index 0000000..a153b8c --- /dev/null +++ b/docs/adr/0015-counter-timezone.md @@ -0,0 +1,36 @@ +# ADR 0015. Модельный день считается в часовом поясе счетчика + +Дата: 28 августа 2026 года. Статус: принято. Реализация — тикет #39. + +## Решение + +Модельные сутки считаются в часовом поясе счетчика UTC+4. `EventDate` обозначает +дату в этом поясе, а `UTCEventTime` — абсолютную метку времени. Граница +модельного дня приходится на полночь счетчика. + +Суточная волна посетителя привязана к его местному времени. У гостя из другого +пояса она сдвигается на разницу с поясом счетчика, но общая граница дня остается +прежней. + +## Почему + +Так устроена выгрузка Метрики: дата события уже посчитана в поясе счетчика. +Полночь в этом поясе не разрезает утренние сессии в регионе мира. Регион можно +выбирать независимо от технического пояса счетчика. + +Для всего потока дня нужна одна граница суток. Местное время гостя описывает +поведение в пределах этих суток, а отдельная дата для каждого посетителя не +нужна. + +## Отклонено + +В разделе 9 отклоненные варианты не зафиксированы. + +## Следствия + +У ночных событий `toDate(UTCEventTime) != EventDate`. Хранилище берет день из +готового `EventDate`, а не выводит его из абсолютной метки. Поворот суточной +волны слегка размывает общий пик активности гостей из разных поясов. + +Решение принято при исполнении тикета #39 и зафиксировано в спеке этапа 2, +раздел 9. diff --git a/docs/adr/0016-cookie-passport.md b/docs/adr/0016-cookie-passport.md new file mode 100644 index 0000000..78fc89b --- /dev/null +++ b/docs/adr/0016-cookie-passport.md @@ -0,0 +1,28 @@ +# ADR 0016. Паспорт куки хранится в плане состава на всю ее жизнь + +Дата: 28 августа 2026 года. Статус: принято. Реализация — тикет #39. + +## Решение + +Браузер, операционная система, устройство, экран и город принадлежат куке на всю +ее жизнь. План состава выбирает и хранит паспорт, а день только переносит его в +поля событий. + +Две куки одного человека получают один город и разные устройства. Броски +паспорта идут после прежних бросков состава, поэтому прежние измерения не +сдвинулись. + +## Почему + +Кука представляет браузер на устройстве, поэтому ее паспорт не должен меняться +между днями. Только план состава связывает две куки с одним человеком. Поэтому +он может задать им один город и разные устройства. + +## Отклонено + +- Выводить паспорт арифметикой из `ClientID`: этот идентификатор не несет + сведений о двухкуковой паре, поэтому по нему нельзя задать двум кукам одного + человека один город и разные устройства. + +Решение принято при исполнении тикета #39 и зафиксировано в спеке этапа 2, +раздел 9. diff --git a/docs/adr/0017-order-numbering.md b/docs/adr/0017-order-numbering.md new file mode 100644 index 0000000..e286288 --- /dev/null +++ b/docs/adr/0017-order-numbering.md @@ -0,0 +1,34 @@ +# ADR 0017. Номер заказа объединяет две проекции покупки + +Дата: 28 августа 2026 года. Статус: принято. Реализация — тикет #40. + +## Решение + +Номер заказа включает день по модельному времени и порядковый номер покупки в +этот день, например `20260603-0042`. Событийная сторона пишет его в +`purchaseID`, бэкенд — в `order_id`; обе стороны берут один номер из плана +покупки. + +Все покупки, попавшие в поток дня, нумеруются в порядке событий и до любых +потерь следующих этапов. + +## Почему + +По читаемому номеру сразу виден день заказа. Один номер соединяет две проекции +одной покупки. Генераторы кликстрима и бэкенда не согласуют его отдельно. + +Присвоение до потерь сохраняет номера остальных заказов: исчезновение одного +события не должно перенумеровывать чужие покупки и разрывать связь с бэкендом. + +## Отклонено + +- Присваивать номера после внесения потерь: одна потеря сдвинула бы все + следующие номера за день и разорвала бы связь уже созданных проекций. + +## Следствия + +`purchaseID` и `order_id` изначально совпадают. Из-за расхождения событие может +исчезнуть после присвоения номера, но номера сохранившихся заказов не меняются. + +Решение принято при исполнении тикета #40 и зафиксировано в спеке этапа 2, +раздел 9. diff --git a/docs/architecture/generator/README.md b/docs/architecture/generator/README.md index 6a762c0..c266af0 100644 --- a/docs/architecture/generator/README.md +++ b/docs/architecture/generator/README.md @@ -50,6 +50,10 @@ make demo Устройство генератора и основания решений записаны в [спеке этапа 2](../../specs/2026-08-01-generator.md), а границы двух источников и склейки — в [мастер-спеке стенда](../../specs/2026-07-30-stand-v2-realism.md). +Основания трех решений записаны в +[ADR 0015](../../adr/0015-counter-timezone.md), +[ADR 0016](../../adr/0016-cookie-passport.md) и +[ADR 0017](../../adr/0017-order-numbering.md), а спека этапа 2 закрыта. Путеводитель появился по тикету [#48](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/48): он отвечает на другой вопрос — как пройти принятое устройство по коду. diff --git a/docs/architecture/generator/cookie-passport.md b/docs/architecture/generator/cookie-passport.md index 87019a5..ccd3385 100644 --- a/docs/architecture/generator/cookie-passport.md +++ b/docs/architecture/generator/cookie-passport.md @@ -23,7 +23,7 @@ сохраняет его на всю жизнь. Броски паспорта стоят после прежнего состава когорты. Правило такого -продолжения подпотока описано в [дереве зерен](seeds.md), а основания выбора -самого паспорта — в [спеке генератора](../../specs/2026-08-01-generator.md), -раздел 9. Зачем стенду нужна двухкуковая пара одного человека, фиксирует +продолжения подпотока описано в [дереве зерен](seeds.md), а выбор +паспорта объясняет [ADR 0016](../../adr/0016-cookie-passport.md). Зачем стенду +нужна двухкуковая пара одного человека, фиксирует [мастер-спека](../../specs/2026-07-30-stand-v2-realism.md), раздел 5. diff --git a/docs/architecture/generator/counter-time.md b/docs/architecture/generator/counter-time.md index af13a5e..e2ac98a 100644 --- a/docs/architecture/generator/counter-time.md +++ b/docs/architecture/generator/counter-time.md @@ -36,5 +36,5 @@ Как часовой пояс меняет представление времени в ClickHouse, описано в разделе «Часовые пояса» [документа о хранилище](../storage.md). Типы и публичный смысл полей записаны в [описании события](../../formats/clickstream-event.md). -Основание модели времени записано в [спеке -генератора](../../specs/2026-08-01-generator.md), раздел 9. +Выбор модели времени объясняет +[ADR 0015](../../adr/0015-counter-timezone.md). diff --git a/docs/architecture/orders/snapshot.md b/docs/architecture/orders/snapshot.md index 1fe1a3c..7ef6407 100644 --- a/docs/architecture/orders/snapshot.md +++ b/docs/architecture/orders/snapshot.md @@ -18,9 +18,10 @@ в трекере — не отдельная порода, а класс B, и делает его событийная сторона выбрасыванием события после присвоения номера ([классы расхождений](fate.md)); -- номер заказа общий у обеих проекций: `order_id` = клиентский `purchaseID`, - читаемый номер «день и порядковый номер покупки» ([спека - генератора](../../specs/2026-08-01-generator.md), раздел 9); нумеруются все +- обе проекции используют один номер заказа: `order_id` = клиентский + `purchaseID`; + читаемый номер «день и порядковый номер покупки» описан в + [ADR 0017](../../adr/0017-order-numbering.md); нумеруются все покупки, дошедшие до потока дня, — до всяких потерь; - скидка заказа выводится из промокода события по таблице «код → скидка» — числу мира, которое этап 3 берёт готовым (спека генератора, разделы 8 и 9). diff --git a/docs/architecture/storage.md b/docs/architecture/storage.md index bd3d97c..8602d97 100644 --- a/docs/architecture/storage.md +++ b/docs/architecture/storage.md @@ -296,7 +296,7 @@ UTC+4), и пересчёт идёт один раз при наполнении **День берётся из `EventDate`.** Дата в поясе счётчика уже посчитана генератором и лежит колонкой, так что суточные срезы группируются по ней и пояса не упоминают вовсе. Почему у ночных событий `toDate(UTCEventTime)` с ней -расходится — [спека генератора](../specs/2026-08-01-generator.md), раздел 9. +расходится, объясняет [ADR 0015](../adr/0015-counter-timezone.md). ## Путь реплицированных таблиц в keeper diff --git a/docs/specs/2026-08-01-generator.md b/docs/specs/2026-08-01-generator.md index 6e780b5..a66c10a 100644 --- a/docs/specs/2026-08-01-generator.md +++ b/docs/specs/2026-08-01-generator.md @@ -1,6 +1,8 @@ # Генератор (этап 2): функциональный мир, детерминизм до байта, контракт схемы, числа скорости -Статус: Accepted — принята владельцем 2026-08-01 (PR #35). +Статус: Accepted — принята владельцем 2026-08-01 (PR #35); закрыта +2026-08-28 как документ этапа 2. Несущие решения — в ADR 0015–0017, новые +решения генератора — в ADR. Дата: 2026-08-01. Мандат — тикет #14 (этап 2, родитель #4), карта #26. Развилки: модель мира (#27), детерминизм от зерна (#28), архитектура вывода (#29), производительность (#30); исследование скорости (#31). @@ -502,354 +504,328 @@ ## 9. Вопросы нарезки этапа 2 (#34): решения и остатки -Туман карты разложен нарезкой по тикетам; решения фиксируются здесь по -мере исполнения. +Журнал закрыт 28 августа 2026 года. Он хранит историю решений этапа 2; основания +модели времени, паспорта куки и номера заказа вынесены в +[ADR 0015](../adr/0015-counter-timezone.md), +[ADR 0016](../adr/0016-cookie-passport.md) и +[ADR 0017](../adr/0017-order-numbering.md). Новые решения генератора +записывают в ADR и в спеке этапа, на котором их принимают. -Решено при исполнении #38 (2026-08-02): +Журнал собран по темам, но дата и тикет остались у каждого решения. По +журналу видно, какие границы затрагивала каждая правка: иногда она меняла +сразу план состава, воронку и каталог. При #40 это было дешево — опись еще +не собрали; позже такой ход потребовал бы пересборки описи. -- **Числа притока и состава.** Приток — ~3 800 новых кук в средний день, - модулируется недельным профилем мира; трафик наследует эту волну через - дневную аудиторию, а не отдельным умножением (уточнение при исполнении - #39 — ниже). Доля одноразовых кук — 75%; - возвращающиеся — в среднем 3–4 возврата, профиль убывающий: почти все - в первые 7–10 дней, тонкий хвост поздних возвратов и повторных - покупок — до края окна (цикл повторной покупки магазина — месяцы). - Хвост возвратов — окно активности человека от его первого дня, - общее на обе его куки, — и глубина предыстории: 90 дней (решение - владельца 2026-08-02: дольше квартала стенд никто не гоняет, а заказы - старых посетителей продолжаются весь прогон; плата — чуть меньше пар, - полностью реализованных внутри 14-дневного снимка). Покупатели - считаются людьми, не куками; доля покупателей — 5% людей когорты, а - людей в когорте почти столько же, сколько кук: вторые куки пар - добавляют меньше процента. Доля — число плана, а не торгового тикета: без него не - отобрать двухкуковые пары; #40 наследует его, не переоткрывая. - Сходимость с разделом 5: средняя кука активна ≈1,9 дня → дневная - аудитория ≈7 100 — середина вилки 6–8 тыс.; уникумов за 14 дней - снимка — под 70 тыс.: новые куки плюс возвраты предыстории (точные - числа — в измерении ниже; не путать с ~50 тыс. событий одного дня) — - рост `uniq(ClientID)` с горизонтом виден сразу; новых покупателей - ~190 в день → конверсия ~2% на сессию. -- **D0 = 2026-06-01, понедельник** — решение владельца при нарезке - (2026-08-01). Дата недавняя, чтобы данные первые месяцы выглядели - свежими; привязки к реальному календарю у констант мира всё равно нет. -- **Измерено на собранном плане** (канонический мир, 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 пар. -- **Конфигурация мира — модуль чистых данных** рядом с контрактом схемы: - все числа мира в одном месте, написанном как приглашение любопытному - менти крутить. Правка модуля — смена мира: чек описи честно - краснеет, опись сторожит только канон. Вне модуля — лишь то, что - мира не меняет: своё зерно и транспортные флаги проигрывателя. - Отклонено: внешний конфиг и env-переопределения — переменная мира, - которую паспорт описи не видит; файл-конфиг в репозитории — по - смыслу равен модулю, но платит загрузчиком и валидацией (довод - раздела 3 против YAML). +### План состава и числа мира -Решено при исполнении #39 (2026-08-02) — решения владельца до реализации: +- **Числа притока и состава** (#38, 2026-08-02). Приток — ~3 800 новых кук в + средний день, модулируется недельным профилем мира; трафик наследует эту волну + через дневную аудиторию, а не отдельное умножение (уточнение #39). Доля + одноразовых кук — 75%; возвращающиеся дают в среднем 3–4 возврата, почти все в + первые 7–10 дней, а тонкий хвост поздних возвратов и повторных покупок тянется + до края окна: цикл повторной покупки магазина измеряется месяцами. Хвост + возвратов — общее на обе куки окно активности человека от первого дня и + глубина предыстории: 90 дней (решение владельца 2026-08-02: дольше квартала + стенд никто не гоняет, а заказы старых посетителей продолжаются весь прогон; + плата — чуть меньше пар, полностью реализованных внутри 14-дневного снимка). + Покупатели считаются людьми, не куками; их доля — 5% людей когорты. Людей + почти столько же, сколько кук: вторые куки пар добавляют меньше процента. Доля + — число плана: без него не отобрать двухкуковые пары, и #40 его не + переоткрывает. Сходимость с разделом 5: средняя кука активна ≈1,9 дня, дневная + аудитория ≈7 100, уникумов за 14 дней — под 70 тыс. Это новые куки и возвраты + предыстории; их не надо путать с ~50 тыс. событий одного дня. Новых + покупателей ~190 в день, что дает конверсию ~2% на сессию. +- **Конфигурация мира — модуль чистых данных** (#38, 2026-08-02) рядом с + контрактом схемы: все числа мира собраны в одном месте, которое менти может + крутить. Правка модуля меняет мир, и проверка описи честно краснеет: опись + сторожит только канон. Вне модуля остается то, что мира не меняет, — свое + зерно и транспортные флаги проигрывателя. Отклонено: внешний конфиг и + переопределения окружением — переменная мира, которой нет в паспорте описи; + файл-конфиг в репозитории равен модулю по смыслу, но платит загрузчиком и + валидацией (довод раздела 3 против YAML). +- **Покупатель перестает быть словом — двумя рычагами сразу** (#40, решение + владельца до реализации, 2026-08-02). Помеченный планом покупатель заметно + чаще доходит до заказа, дольше живет и чаще возвращается. Склонность покупать + — свойство человека, а ее след в анонимном кликстриме виден только как долгая + жизнь куки и повторные покупки. Один рычаг этого не дает: кука живет меньше + двух визитов за снимок, и второй покупке негде случиться. Рычаг жизни лежит в + плане (#38), рычаг конверсии — в воронке (#39); помеченный отличается на обоих + шагах — чаще доходит до корзины и реже бросает ее. Помеченному дано 18% до + корзины и 60% из корзины в оформление против 6% и 45% у обычного посетителя. + Обычная доля снижена с 8% до 6%: помеченные живут дольше и занимают большую + долю дневной аудитории, поэтому прежнее число увело бы общую конверсию за + вилку раздела 5. Рычаг метки не должен оплачиваться ростом конверсии; расти + внутри вилки аудитории миру можно. Вольные покупки по-прежнему решает день + (уточнение к разделу 1), но не одинаковой для всех случайностью. Отклонено: + *оставить одинаковый бросок* — покупатель остается именем без следа; *поднять + только конверсию* — короткой жизни не хватает для повторных покупок; + *разрешить покупать только помеченным* — их малая доля дала бы слишком мало + заказов, а ее рост завысил бы число двухкуковых пар. Промежуточные числа + пересчитаны при #50; актуальные — в таблице замера #50. +- **Счетчика покупок в плане состава не заводится** (#40, решение владельца до + реализации, 2026-08-02). Тикет просил свести с планом покупки и двухкуковые + пары, но раздел 1 обещает до генерации только состав — посетителей, приток и + пары. Торговые счетчики складываются из поведения дня. Квота заставила бы день + добирать точное число и сделала бы конверсию подозрительно ровной: условность + была бы видна именно там, куда смотрит лаба. Сходятся пары; покупки + проверяются после генерации по дню и вилке конверсии. -- **Каталог товаров заводится здесь, а не в #40.** У карточки товара есть - `Title` — имя товара, а имена живут в каталоге: карточка без каталога - невозможна, и без карточек #39 сделал бы примерно половину трафика. - Файл `data/catalog/products.csv` (`sku`, `name`, `category`, `brand`, - `price`) — общий у генератора и словаря ClickHouse; #40 получает готовый. - Решена форма, а не длина: артикул — четыре буквы категории и четыре - цифры, цена — целые копейки, категорий шесть. Строки дописываются - механически, поэтому ни код, ни тесты их не считают, а товар для карточки - выбирается равномерно внутри категории. -- **Ассортимент — непродовольственная розница** (товары для дома, текстиль, - посуда, мелкая бытовая техника, детское, одежда и обувь), ~150–200 sku. - Довод не вкусовой: числа мира приняты под этот профиль — цикл повторной - покупки в месяцы, 75% одноразовых кук, конверсия ~2% на визит. - Продуктовая сеть требовала бы других чисел, то есть переоткрытия #38. -- **География — один регион присутствия**: Поволжье с центром в Самаре, - города своего региона и тонкий хвост остальной страны. «Топ городов - России» дал бы магазину с одним складом карту, которой у него не бывает. -- **Модельные сутки считаются в часовом поясе счётчика** (UTC+4), как в - выгрузке Метрики: `EventDate` — дата в поясе счётчика, `UTCEventTime` — - абсолютная метка. Шов суток приходится на местную полночь и не режет - утренние сессии, а регион мира выбирается свободно. Суточная волна задана - в местном времени посетителя: гостю из другого пояса профиль - поворачивается на разницу, и пик слегка размазывается — как в жизни. - Следствие для соседних этапов: `toDate(UTCEventTime)` ≠ `EventDate`; оно - записано в описание выгрузки, иначе сторона хранилища выведет дату сама и - разойдётся на несколько часов данных. -- **Паспорт куки — постоянная часть мира, а не поведение дня.** Браузер, ОС, - устройство, экран и город у куки одни во всех её днях: кука — это браузер - на устройстве. Держит их план состава (`Cohort` и `DayAudience` прирастают - двумя массивами), разворачивает в поля события день-функция. Выводить - паспорт арифметикой из `ClientID` дешевле, но про двухкуковые пары знает - только план, а два города у одного человека — ложь в данных; пара - получает один город и разные устройства («телефон и ноутбук», - мастер-спека, раздел 5). Броски паспорта приписаны последними, поэтому - измеренные числа канонического мира не сдвинулись: прогон счётчиков до и - после дал те же 3 828 / 6 235–7 124 / 68 119 / 170. -- **Справочники — таблицы-литералы** рядом с конфигурацией мира; Faker и - mimesis не подключаются. Нужны не случайные строки, а связки (город → id - региона → часовой пояс; телефон → Safari → iOS → размер экрана) — их - фейкер не даёт, таблицу пришлось бы написать всё равно, а новая - зависимость молча меняла бы мир при обновлении своих словарей. -- **Гео-id — настоящие числа геобазы Яндекса.** Проверка 2026-08-02: id - подтверждались обращением к живым сервисам Яндекса по тому же номеру - (`yandex.ru/pogoda/`, `yandex.ru/maps/225/russia/`) — страница - открывает ожидаемое место. Оговорка стоит в коде: опубликованной таблицы - геобазы найти не удалось, а что `RegionCityID` Метрики нумерует регионы - той же геобазой — обоснованное допущение, не подтверждённый источником - факт. Выдуманные по памяти числа не годятся ни в каком случае. -- **IP-адреса — нероутируемые диапазоны**: документационные сети RFC 5737 и - benchmark-сеть 198.18/15 по ломтю на город, телефонам — CGNAT 100.64/10, - как у настоящих операторов. Правдоподобные публичные адреса принадлежат - живым организациям, и в учебных данных им не место. -- **Словарь: «визит» остаётся за сессией** и полем `VisitID`. Одноимённое - понятие плана состава переименовано в «день активности»: у куки он один на - день, а визитов внутри дня бывает несколько. +### Время, визиты и границы дня -Решено при исполнении #39 самой реализацией: +- **D0 = 2026-06-01, понедельник** (#38, решение владельца 2026-08-01). Недавняя + дата делает первые месяцы данных свежими, но к календарю запуска константа + мира не привязана. +- **Модельные сутки считаются в часовом поясе счетчика** (#39, решение владельца + до реализации, 2026-08-02), а местная волна гостя поворачивается — + [ADR 0015](../adr/0015-counter-timezone.md). +- **Слово «визит» остается за сессией** (#39, решение владельца до реализации, + 2026-08-02) и полем `VisitID`. Одноименное понятие плана переименовано в «день + активности»: у куки он один на день, а визитов внутри дня бывает несколько. +- **Правила резки визитов** (#39, 2026-08-02; мастер-спека, раздел 1.2, + требовала их задокументировать): визит принадлежит одной куке; пауза дольше 30 + минут рвет его надвое; граница модельных суток режет визит, события за полночь + в день не попадают. Единственное исключение — визит с заказом, обещанным + планом двухкуковых пар: его старт сдвигается назад, чтобы воронка уместилась в + сутки. Это принятое ограничение модели, а не недосмотр: обещание плана — + гарантия, на которой стоит лаба склейки, и ради нее свобода старта сужена. + Цена названа и мала: около 24 визитов из ~9,5 тыс. в день никогда не + начинаются в последние минуты суток. Правила лежат в докстринге `day.py`, а + тест заново собирает визиты и сверяет их с `VisitID`. +- **Недельная волна применяется один раз** (#39, 2026-08-02). Профиль ведет + приток, а трафик наследует его через дневную аудиторию; измеренный размах + трафика — ±7% против ±10% у притока. Второе умножение удвоило бы недельный + размах. Если недельной лабе не хватит сигнала, нужен отдельный слабый профиль + активности, а не повторное умножение: «приходит меньше новых людей» и «каждый + ходит меньше» — разные механизмы. +- **Торговый хвост умещается в сутки резервом, а не зажимом** (#40, решение + владельца до реализации, 2026-08-02). Событие покупки встает на несколько + секунд позже страницы подтверждения, корзина — позже карточки, а граница суток + режет вышедшее за нее. Визит с обещанным заказом уже был подвинут так, чтобы + воронка уместилась в сутки, но без учета торгового хвоста. Резерв расширен на + хвост: зажим к последней секунде сломал бы правило задержки ровно в + пограничном случае. Визит со срезанным полуночью подтверждением покупки не + дает. +- **Торговый хвост разводит соседние визиты** (#40, 2026-08-02): таймаут + считается от последнего события, а не страницы. Иначе лаба сессий склеила бы + два визита и разошлась с `VisitID`; решение поймано тестом сборки визитов. +- **Полночь забирает подтверждение вместе с покупкой** (#40, 2026-08-02). + Сначала резерв считался только для обещанных планом заказов, поэтому у вольной + покупки оставалось окно: подтверждение дожило до потока, а покупка вышла за + полночь. Это создало бы потерю на клиентской стороне по недосмотру. Теперь + запас есть у всякой страницы подтверждения: либо за границу суток уходят обе + строки, либо остаются обе. В каноническом мире этот случай за 14 дней не + выпал. +- **Задержка торгового события короче самой короткой паузы между страницами** + (#40, 2026-08-02). Событие корзины не обгоняет страницу кнопки и не выходит за + полночь: за ним в визите всегда есть страница, пережившая полночь. -- **Правила резки визитов** (мастер-спека, раздел 1.2 требовала их - задокументировать): визит принадлежит одной куке; пауза дольше 30 минут - рвёт визит надвое; граница модельных суток режет визит, события за - полночь в день не попадают. Единственное исключение — визит с заказом, - обещанным планом двухкуковых пар: его старт сдвигается назад, чтобы - воронка уместилась в сутки. Это принятое ограничение модели, а не - недосмотр: обещание плана — гарантия, на которой стоит лаба склейки, и - ради неё мы сужаем свободу старта. Цена названа и мала: около 24 визитов - в день никогда не начинаются в последние минуты суток (из ~9,5 тыс.). - Правила лежат в докстринге `day.py`, и тест собирает визиты по ним - заново, сверяя сборку с `VisitID`. -- **Числа дня**: ≈1,4 визита на день активности куки и ≈4,8 страницы на - визит. Воронка при исполнении #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% у притока. Второе умножение удвоило бы недельный размах. Если - недельной лабе однажды не хватит сигнала, принципиальный путь — отдельный, - более слабый профиль активности, а не повторное применение профиля - притока: механизмы разные, и «на выходных приходит меньше новых людей» — - не тот же факт, что «на выходных каждый ходит меньше». -- **`ParsedParamsKey1` остаётся пустой** — это отложенное решение, а не - дыра: вариант A/B-теста был бы постоянной куки, а не поведением дня, и - заводится тикетом #47. -- **Шов для #40**: день отдаёт, кроме колонок, два выровненных по строкам - ряда — какая это страница магазина и какой товар показывала карточка. По - ним #40 знает, куда вешать торговое событие и что посетитель на самом - деле смотрел: товар в корзине, которого никто не открывал, — видимая - глупость в воронке. Визит с назначенным заказом всегда доходит до - `/confirmation`, а перед корзиной у него всегда есть карточка. Своей - случайности #39 у #40 не занимает: подпоток `COMMERCE` не тронут. +### Каталог, география и паспорт куки -Решено при исполнении #40 (2026-08-02) — решения владельца до реализации. -Три решения выходят за границы тикета: правятся план состава (#38), воронка -(#39) и каталог товаров (#39). Сейчас это дёшево — опись ещё не собрана -(#42); позже обошлось бы её пересборкой. Часть решений принята после -холодного ревью первой редакции блока. +- **Каталог товаров заводится в #39, а не в #40** (#39, решение владельца до + реализации, 2026-08-02). Карточке нужен `Title`, а имя товара живет в + каталоге: без каталога #39 сделал бы лишь половину трафика. + `data/catalog/products.csv` (`sku`, `name`, `category`, `brand`, `price`) + общий для генератора и словаря ClickHouse; #40 получает его готовым. Решена + форма, а не длина: артикул — четыре буквы категории и четыре цифры, цена — + целые копейки, категорий шесть. Строки дописываются механически, поэтому ни + код, ни тесты их не считают; товар для карточки выбирается равномерно внутри + категории. +- **Ассортимент — непродовольственная розница** (#39, решение владельца до + реализации, 2026-08-02): товары для дома, текстиль, посуда, мелкая бытовая + техника, детское, одежда и обувь, ~150–200 sku. Довод не вкусовой: под этот + профиль приняты месячный цикл повторной покупки, 75% одноразовых кук и + конверсия ~2% на визит. Продуктовая сеть потребовала бы других чисел и + переоткрыла бы #38. +- **География — один регион присутствия** (#39, решение владельца до реализации, + 2026-08-02): Поволжье с центром в Самаре, города региона и тонкий хвост + остальной страны. «Топ городов России» дал бы магазину с одним складом карту, + которой у него не бывает. +- **Паспорт куки хранит план состава на всю ее жизнь** (#39, решение владельца + до реализации, 2026-08-02); пара получает один город и разные устройства + («телефон и ноутбук», мастер-спека, раздел 5), а броски паспорта стоят + последними — [ADR 0016](../adr/0016-cookie-passport.md). Прежние счетчики + пересчитаны при #40; актуальные числа — в таблице замера #50. +- **Справочники — таблицы-литералы** (#39, решение владельца до реализации, + 2026-08-02) рядом с конфигурацией мира; Faker и mimesis не подключаются. Нужны + связки вроде «город → id региона → часовой пояс» и «телефон → Safari → iOS → + экран»: генератор строк их не дает, таблицу пришлось бы написать все равно, а + обновление зависимости молча меняло бы мир. +- **Гео-id — настоящие числа геобазы Яндекса** (#39, решение владельца до + реализации; проверка 2026-08-02). Id подтверждались обращением к живым + сервисам Яндекса по тому же номеру (`yandex.ru/pogoda/`, + `yandex.ru/maps/225/russia/`) — страница открывает ожидаемое место. Оговорка + стоит в коде: опубликованной таблицы геобазы найти не удалось, а что + `RegionCityID` Метрики нумерует регионы той же геобазой — обоснованное + допущение, не подтвержденный источником факт. Выдуманные по памяти числа не + годятся ни в каком случае. +- **IP-адреса — нероутируемые диапазоны** (#39, решение владельца до реализации, + 2026-08-02): сети RFC 5737 и 198.18/15 по ломтю на город, телефонам — CGNAT + 100.64/10, как у настоящих операторов. Правдоподобные публичные адреса + принадлежат живым организациям, и в учебных данных им не место. +- **Вариант товара — справочник мира** (#40, 2026-08-02). Сырой `ecommerce` + несет `variant`, которого нет в плоских колонках; строки живут рядом со + справочниками устройств и городов, а не сочиняются на месте. -- **Корзина шире заказа.** Посетитель кладёт в корзину товары тех карточек, - которые открывал в этом визите, а покупает не всё: около 15% позиций - остаются брошенными. Иначе событие корзины не рассказывало бы ничего сверх - покупки — заказ был бы её точной копией, и сравнивать было бы нечего. - Замерено на мире до правок: у покупающего визита 2,1 карточки, в заказе - выходит 1,9 позиции, однопозиционных заказов около половины. Карточка, - открытая в визите дважды, даёт одну позицию, а не две: повторный просмотр — - раздумье, а не второй товар. Количество штук — свой бросок: обычно одна, - изредка две-три. Отклонено: *заказ всегда из одного товара* — список - позиций как урок умирает, а на нём стоит единственный носитель навыка - «вложенный JSON» (мастер-спека, раздел 2); *досыпать в корзину товары, - которых посетитель не открывал* — ломает шов #39 ровно там, ради чего он - строился; *бросать много* (40% и выше) — две трети заказов схлопываются в - один товар. -- **Покупатель перестаёт быть словом — двумя рычагами сразу.** Метка плана - («этот человек склонен покупать», 5% людей когорты) до сих пор ни на что не - влияла: кто купит, решал одинаковый для всех бросок в дне. Теперь - помеченный, во-первых, доходит до заказа заметно чаще прочих, во-вторых, - дольше живёт и чаще возвращается. Первый рычаг — про правдоподобие: - склонность покупать и вправду свойство человека, а не свойство визита. - Второй — про видимость: метки в событии нет и не будет (кликстрим - анонимен), поэтому «постоянный покупатель» читается в данных только как - кука, которая ходит неделями и покупает не раз. Одним лифтом это - недостижимо: кука живёт меньше двух визитов за снимок, и второй покупке - негде случиться — лифт поднял бы долю кук с двумя покупками с 2% до 6% и на - том исчерпался. Рычаг жизни лежит в плане (#38), рычаг конверсии — в - воронке (#39); правятся оба. Отличается помеченный на обоих шагах воронки: - и до корзины доходит чаще, и бросает её реже. В жизни различаются оба — кто - пришёл смотреть, тот и кладёт реже, и до конца доводит реже; один шаг дал - бы половину картины. Числа под каждый шаг — калибровка: помеченному 18% до - корзины и 60% из корзины в оформление против 6% и 45% у обычного - посетителя. Обычного калибровка тоже задела — его доля до корзины опущена - с 8% до 6%, и это единственное число мира, изменённое вне двух рычагов. - Довод: помеченные живут дольше и занимают уже 12% дневной аудитории вместо - 5% людей когорты, поэтому при прежних 8% конверсия мира ушла бы к 2,9% — - за вилку. Рычаг метки не должен оплачиваться ростом конверсии, и границы - правки — вилки раздела 5: дневная аудитория остаётся в пределах - 6–8 тыс., средний день — около 50 тыс. событий, конверсия визита — - около 2%. Расти внутри вилки аудитории - разрешено: помеченные ходят чаще, и это по-прежнему правдоподобный магазин. - Измерено после правки (канонический мир, 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: вольные покупки - по-прежнему решает день, но - случайность в них больше не одинакова для всех. Отклонено: *оставить - монетку* — «покупатель» остаётся именем без следа в данных; *один лифт без - долгой жизни* — довод выше, повторных покупок он почти не прибавляет; - *покупают только помеченные* — 5% людей дают около 40 заказов в день вместо - 220, вернуть их можно, лишь подняв долю покупателей до 10–30% людей, а с - ней вырастет и число двухкуковых пар. -- **Событие корзины садится на карточку товара**, событие покупки — на - страницу подтверждения. В жизни событие корзины шлётся нажатием кнопки на - карточке, а не открытием страницы корзины; при нескольких товарах в заказе - иначе его и не разложить — событий столько, со скольких карточек положили. -- **Номер заказа читаемый**: день модельного времени и порядковый номер - покупки в этом дне (`20260603-0042`). Магазины так и нумеруют, и по номеру - сразу видно день. Он же — `order_id` бэкенда: обе стороны получают один - номер, по нему соединяется сверка (мастер-спека, раздел 4). Нумеруются все - покупки, дошедшие до потока дня, в порядке событий — до всяких потерь. - Расхождения этапа 6 выбрасывают событие, когда номер уже присвоен: иначе - одна потеря перенумеровала бы чужие заказы, и мост к бэкенду разъехался бы. -- **Промокод в событии есть, скидки в сумме нет.** Часть заказов уходит с - промокодом, но выручка, которую шлёт клиент, — сумма позиций без скидки и - доставки. Так и в жизни: код на сайте знает корзину, а не итог расчёта. - Класса расхождения промокод не даёт: сверка приведена к `items_total`, где - скидки и нет (мастер-спека, раздел 7). Он объясняет разрыв между клиентской - суммой и итогом заказа — одну из причин, по которым «суммы не сойдутся» - (мастер-спека, раздел 4). Таблица «код → скидка» — число мира и живёт - здесь: этап 3 берёт её готовой, а не сочиняет заново. Отклонено: - *промокода нет никогда* — колонка формата осталась бы мёртвой. -- **Деньги: копейки внутри, рубли в колонках, дробь только в выручке — и - копейки в части цен каталога.** Генератор считает целыми копейками; цена - товара в событии — целые рубли, как у Метрики (`productPrice` там `Int64`); - дробное число одно — `purchaseRevenue` типа `Float64`. Все 180 цен каталога - кратны рублю, и это делало урок про Float64 беспредметным: округлять - нечего, копейки существуют только на словах, а расхождение сумм пришлось бы - выдумывать вероятностью. Поэтому часть цен получает копейки (1 299,90 ₽ — - обычная розница): `productPrice` округляется самим форматом, а - `purchaseRevenue` несёт точную сумму. Разрыв живёт внутри события — считать - выручку по разобранным массивам нельзя, и это настоящий урок формата, а не - придуманный. -- **Массивы `purchase*` и `product*` разной длины между собой.** Внутри своей - группы длина общая: `purchase*` — по элементу на заказ (у нас всегда один), - `product*` — по элементу на товар. Мастер-спека (раздел 1) говорит о - «параллельных массивах одной длины», и прочесть это как «все массивы - события одной длины» легко — поэтому в описании выгрузки сказано прямо. -- **Сырой `ecommerce` несёт больше, чем плоские колонки.** Лаба «сырое против - разобранного» (мастер-спека, раздел 1.2) имеет смысл, только если в сыром - лежит то, чего в массивах нет: бренд товара, его вариант, блок - `actionField` целиком. Собирает строку канонический сериализатор — руками - это второй сериализатор со своим экранированием; зависимость `orjson`, - названная рабочим выбором в разделе 4, появляется здесь, а не в #41. -- **Торговый хвост умещается в сутки резервом, а не зажимом.** Событие - покупки встаёт на несколько секунд позже просмотра страницы подтверждения, - событие корзины — позже своей карточки, а граница суток режет всё, что за - неё вышло. Визит с обещанным планом заказом уже подвинут так, чтобы - уместиться в сутки (#39), но подвинут по страницам — торгового хвоста в том - расчёте не было. Резерв расширяется на хвост: тогда правило «покупка на - секунды позже подтверждения» держится всегда, а зажим к последней секунде - ломал бы его ровно там, ради чего написан. Визит, у которого страница - подтверждения срезана полуночью (1,2% покупающих), покупки не даёт — заказа - не было. -- **Счётчика покупок в плане состава не заводится.** Тикет просил «счётчики - покупок и двухкуковых пар сходятся с планом», но покупок план не считает и - считать не будет: раздел 1 обещает до генерации только состав — посетителей, - приток, пары, — а торговые счётчики складываются из поведения дня. Квота на - покупки заставила бы день добирать до точного числа, и конверсия в данных - вышла бы подозрительно ровной — условность видна там, куда лаба смотрит. - Сходятся пары; покупки проверяются постфактум по сгенерированному дню и по - вилке конверсии (~2% на визит). +### Корзина и состав заказа -Решено при исполнении #40 самой реализацией: +После приемки #40 (2026-08-02) на грилинге «просмотр → корзина» нашли два +точных равенства. В живых данных таких равенств нет: аналитик прочитает +их как склейку разметки вместо поведения людей. -- **Торговый хвост разводит и соседние визиты, а не только сутки.** Правило - резки требует, чтобы визиты одной куки стояли дальше таймаута; считать это - расстояние от последней страницы, когда последним событием визита бывает - покупка, значило отдать таймауту торговый хвост — лаба сессий склеила бы - два визита в один и разошлась бы с `VisitID`. Расстояние считается от - последнего события; поймано тестом сборки визитов. -- **Полночь забирает подтверждение вместе с его покупкой.** Резерв суток - сначала считался только для визитов с обещанным планом заказом, и у - вольной покупки оставалось окно в несколько секунд: страница - подтверждения дожила до потока, а событие покупки за полночь вышло. Это - была бы потеря на клиентской стороне — класс этапов 4 и 6, заведённый по - недосмотру. Теперь запас под торговый хвост есть у всякой страницы - подтверждения: либо она уходит за границу суток вместе с покупкой, либо - остаются обе. Это ровно то правило, которое блок выше называет словами - «визит, у которого страница подтверждения срезана полуночью, покупки не - даёт»; в каноническом мире числа от правки не сдвинулись — случай не - выпал ни разу за 14 дней. -- **Задержка торгового события короче самой короткой паузы между - страницами.** Тогда событие корзины не обгоняет страницу, на которой - посетитель нажал кнопку, и не выходит за полночь: за ним в том же визите - всегда идёт страница, которая полночь пережила. -- **Номера торговых событий — из торгового подпотока.** `WatchID` строки - корзины и покупки рисует `COMMERCE`: возьми их у трафика, и правка - торгового поведения сдвинула бы трафиковые номера. Ряды двух подпотоков - проверяются на непересечение — обещание уникальности держит дедупликацию - при переигровке дня, и предполагать его нельзя. -- **Вариант товара — справочник мира.** Сырой `ecommerce` несёт `variant`, - которого в плоских колонках нет; строки живут в справочниках рядом с - устройствами и городами, а не сочиняются на месте. +- **Корзина шире заказа** (#40, решение владельца до реализации, 2026-08-02). + Посетитель кладет товары открытых карточек, а покупает не все. Иначе событие + корзины ничего не рассказывало бы сверх покупки: заказ был бы ее точной + копией. Карточка, открытая дважды, дает одну позицию — повторный просмотр + означает раздумье, а не второй товар. Количество штук — отдельный бросок: + обычно одна, изредка две-три. Отклонено: *заказ всегда из одного товара* — + список позиций как урок умирает, а на нем стоит единственный носитель навыка + «вложенный JSON» (мастер-спека, раздел 2); *досыпать неоткрытые товары* — + ломается шов #39; *бросать много позиций* — большинство заказов схлопывается в + один товар. Доля брошенных позиций позже пересмотрена в #50. +- **Событие корзины садится на карточку товара** (#40, решение владельца до + реализации, 2026-08-02), событие покупки — на подтверждение. В жизни корзину + шлет кнопка на карточке, а при нескольких товарах события иначе не разложить + по открытым карточкам. +- **Корзина отвязана от страницы `/cart`** (#40, 2026-08-02). До правки + множества визитов с событием корзины и со страницей `/cart` точно совпадали; + пересчитано при #50, актуальные числа — в таблице замера #50. Теперь положить + может любой визит, открывший карточку; `/cart` остается шагом воронки, а заказ + по-прежнему рисуется только после подтверждения. У визита на `/cart` всегда + есть хотя бы одна позиция — страница пустой корзины невозможна, — но открыть + корзину после добавления необязательно. Так в мире появляются те, кто положил + товар и не открыл корзину, — массовый сюжет магазина. Ошибка прошла нарезку, + потому что воронку #39 описали страницами, решение #40 — событиями, а вопрос + «какой визит кладет» заново не задали: страничное правило молча переехало на + уровень событий. +- **Намерение визита берется из шага воронки, а не из метки покупателя** (#50, + 2026-08-02). Визит на `/cart` кладет почти все сравненное, прочий — редко. + Метка уже влияет через воронку; второе слагаемое посчитало бы ее дважды и + протянуло бы план состава через шов страниц и товаров. Плата: у визита без + намерения добавление не связано со свойством человека, но склонные покупать и + так преобладают среди дошедших до `/cart`. +- **Доля брошенных позиций опущена с 15% до 10%** (#50, 2026-08-02). Корзина + сузилась с 2,4 позиции до 1,7, и прежние 15% чаще выносили ее целиком: + спасенная позиция возвращала заказ к одному товару, и однопозиционных заказов + набиралось под 60%. Свойство сохранено, число перемерено — ровно так, как блок + #40 и оговаривал. -Решено при исполнении #40 после приёмки (2026-08-02) — грилинг тождества -«просмотр → корзина». Приёмка нашла два точных равенства: каждое в живых -данных не встречается и потому читается аналитиком как склейка в разметке, -а не как поведение людей. +### Спрос товара и границы калибровки -- **Корзина отвязана от страницы корзины.** Замерено до правки на всех 14 - днях канонического мира: множества «визиты с событием корзины» и «визиты - со страницей `/cart`» совпадали в точности — 828 и 828 на дне 2, и так - каждый день без единого расхождения. Событие корзины - возникало только у визита, дошедшего до воронки, то есть факт добавления - идеально предсказывался более поздним просмотром страницы. Теперь - положить может любой визит, открывший карточку; страница `/cart` - остаётся шагом воронки и решается как прежде, а заказ по-прежнему - рисуется только у визита, дошедшего до подтверждения. Оговорка, без - которой появилась бы новая ложь вместо старой: у визита, дошедшего до - `/cart`, хотя бы одна позиция в корзине есть всегда — страница корзины - при пустой корзине невозможна. Брошенные корзины в мире были и до правки - (в том же дне 828 корзин против 272 покупок); правка добавляет к ним - тех, кто положил и корзину не открывал, — самый массовый сюжет магазина. - Почему это проехало при нарезке: воронка #39 записана в терминах - страниц, #40 принял решение про события («событие корзины садится на - карточку»), но вопрос «какой визит вообще кладёт» заново не задал — - страничное правило молча переехало на уровень событий. -- **Класть или нет решает и товар, а не только визит.** До правки в - корзину ложились все карточки покупающего визита: внутри воронки 100%, - вне её 0%. Привлекательность товара измерить было нечем — конверсия - «просмотр → корзина» по sku давала чистый шум, а вопрос «какие товары - кладут чаще» ответа не имел. Теперь у товара есть уровень спроса: - колонка каталога с тремя значениями (магнит, обычный, залёживается), а - вероятности трёх уровней — числа мира рядом с остальными. Отклонено: - *свой процент у каждого из 180 sku* — рукописные числа, которых никто не - обоснует, а каталог по решению #39 дописывается механически; *выводить - склонность из цены* — жёсткая связь, в которой менти найдёт ровно то, - что мы в неё вложили, и ничего сверх. -- **Границы правки — те же вилки раздела 5**: аудитория 6–8 тыс., средний - день около 50 тыс. событий, конверсия визита около 2%. Заказ рисуется из - корзины, а корзина сузилась, поэтому доля брошенных позиций и состав - заказа перемеряются вместе с остальным: сохраняется не число, а - свойство — заказ уже корзины и однопозиционных заказов около половины. +- **Класть или нет решает и товар, а не только визит** (#40, 2026-08-02). Раньше + внутри воронки клали все карточки, вне ее — ни одной: конверсия по sku давала + чистый шум, и вопрос «какие товары кладут чаще» не имел ответа. Теперь в + каталоге три уровня спроса: магнит, обычный и залеживающийся, а их вероятности + — числа мира. Отклонено: *свой процент у каждого из 180 sku* — необоснованные + рукописные числа в механически дополняемом каталоге; *выводить спрос из цены* + — жесткая связь, в которой менти найдет только вложенное авторами правило. +- **Два ряда вероятностей, а не ряд с множителем** (#50, 2026-08-02): один для + визита с намерением, другой для смотрящего. У намеренного визита вероятности + высоки, иначе корзина и заказ схлопываются до одного товара; возле 100% + множитель съел бы разницу уровней и сделал бы конверсию уровней одинаковой. + Разрыв внутри рядов разный: у смотрящего товар решает почти все, а у + пришедшего покупать заслонен намерением. +- **Ни одному уровню спроса нельзя дать нулевую вероятность** (#50, 2026-08-02). + Ноль у залежавшегося товара вернул бы исходное равенство на четверти каталога: + добавление случалось бы только внутри визита с `/cart`. Отвязка либо относится + ко всему ассортименту, либо это не отвязка. Поэтому уровни разводятся не нулем + внизу второго ряда, а ослаблением залежавшегося товара в ряду визита с + намерением: пришедший покупать берет его реже ходового. +- **Уровень спроса — колонка `demand` в конце файла каталога** (#50, + 2026-08-02), значения словами («магнит», «обычный», «залёживается»). Раздан он + одной колодой на весь каталог: доли по категориям расходятся сами (магнитов от + четырёх до девяти на тридцать строк). Раскладывать колоду поровну в каждую + категорию было бы той же неправдоподобной ровностью, какую тикет вычищал из + данных. Связь с ценой отклонена выше, и тест сторожит её отсутствие — по + четвертям цены и по медиане, потому что одни только края списка обманываются + четырьмя исключениями. Хвост соседям: словарь ClickHouse над этим файлом ещё + не построен (#37/#43) — колонку надо взять в его описание сразу, а не догонять + правкой; описи нужен хеш каталога (хвост #42) тем более, потому что теперь в + файле живёт ещё и поведение. +- **Доля просмотров карточек, доходящих до корзины, выросла с 7,3% до 8,6%** + (#50, 2026-08-02) и подошла к потолку вилки. Визитам вне `/cart` нужны свои + позиции, а визит с намерением должен класть почти все сравненное; обе границы + тянут число вверх, и опустить долю можно лишь ценой одной из них. Место до 9% + невелико, следующая правка торговой половины считается против него. +- **Границы правки — вилки раздела 5** (#40, 2026-08-02): аудитория 6–8 тыс., + средний день около 50 тыс. событий, конверсия визита около 2%. Корзина и + состав заказа перемеряются вместе; сохраняется свойство — заказ уже корзины, + однопозиционных заказов около половины. +- **Вилка 5–9% — ориентир, а не граница** (#50, решение владельца 2026-08-02). У + живых магазинов эта доля гуляет широко; пока она правдоподобна, сама по себе + ничего не сторожит, и подгонять поведение под ее край не надо. Настоящий + предел считается в другой валюте — дневной бюджет событий, на котором стоит + опись #42. Если будущая правка упрется в него, двигать надо бюджет и его + причины, а не долю. -**Измерено после правки #50** (канонический мир, 14 дней; правый столбец — -то же до правки). Доли по уровням и состав заказа считаются по окну снимка -целиком, а не как среднее дневных долей: заказов в дне около 240, и на одном -дне такая доля гуляет на несколько пунктов от одной случайности выборки. -Трафиковая половина не сдвинулась ни на строку: набор просмотров страниц -сверен со снимком, снятым до первой правки. +### Номер заказа, промокод и деньги + +- **Номер заказа — день и порядковый номер покупки** (#40, решение владельца до + реализации, 2026-08-02): событийная и бэкенд-сторона берут один номер из плана + — [ADR 0017](../adr/0017-order-numbering.md); по нему соединяется сверка + (мастер-спека, раздел 4). +- **Промокод в событии есть, скидки в сумме нет** (#40, решение владельца до + реализации, 2026-08-02). Клиент шлет сумму позиций без скидки и доставки, + поэтому код объясняет разрыв с итогом заказа. Класса расхождения промокод не + дает: сверка приведена к `items_total`, где скидки нет (мастер-спека, раздел + 7). Код объясняет одну из причин, по которым «суммы не сойдутся» + (мастер-спека, раздел 4). Так и в жизни: код на сайте знает корзину, а не + окончательный расчет. Таблица «код → скидка» — число мира, которое этап 3 + берет готовым, а не сочиняет заново. Отклонено: *промокода нет никогда* — + колонка формата осталась бы мертвой. +- **Деньги: копейки внутри, рубли в колонках, дробь только в выручке** (#40, + решение владельца до реализации, 2026-08-02). Генератор считает целыми + копейками; цена товара в событии — целые рубли, как у Метрики (`productPrice` + там `Int64`); дробное число одно — `purchaseRevenue` типа `Float64`. Все 180 + цен каталога были кратны рублю, и это делало урок про `Float64` беспредметным: + округлять нечего, копейки существовали только на словах. Поэтому часть цен + получила копейки (1 299,90 ₽ — обычная розница): `productPrice` округляется + самим форматом, а `purchaseRevenue` несет точную сумму. Разрыв живет внутри + события — считать выручку по разобранным массивам нельзя, и это настоящий урок + формата, а не придуманный. + +### Событийный шов, формат и случайность + +- **Шов для #40** (#39, 2026-08-02): день отдает выровненные по строкам ряды + страницы магазина и товара на карточке. По ним торговая половина знает, куда + повесить событие и какой товар посетитель видел: товар в корзине, которого + никто не открывал, был бы видимой ошибкой воронки. Назначенный заказ всегда + доходит до `/confirmation`, перед корзиной есть карточка. #39 не занимает + случайность #40: подпоток `COMMERCE` остается нетронутым. +- **Номера торговых событий — из торгового подпотока** (#40, 2026-08-02). + `WatchID` корзины и покупки рисует `COMMERCE`, поэтому правка торговли не + сдвигает трафиковые номера. Ряды подпотоков проверяются на непересечение: + уникальность нужна дедупликации при переигровке дня. +- **Массивы `purchase*` и `product*` разной длины между собой** (#40, решение + владельца до реализации, 2026-08-02). Внутри группы длина общая: `purchase*` — + по элементу на заказ, здесь всегда один, `product*` — по элементу на товар. + Мастер-спека (раздел 1) говорит о «параллельных массивах одной длины», и + прочесть это как общую длину всех массивов события легко — поэтому в описании + выгрузки сказано прямо. +- **Сырой `ecommerce` несет больше, чем плоские колонки** (#40, решение + владельца до реализации, 2026-08-02): бренд, вариант и `actionField` целиком. + Лаба «сырое против разобранного» (мастер-спека, раздел 1.2) имеет смысл, + только если в сыром есть сведения, которых нет в массивах. Строку собирает + канонический `orjson`-сериализатор, а не второй ручной сериализатор со своим + экранированием (рабочий выбор раздела 4). + +### Замеры канонического мира + +- **Измерено на собранном плане** (#38, перемерено при #40, 2026-08-02): приток + 3 828 кук в день в среднем, дневная аудитория 6 637–7 595 — обе величины в + вилках раздела 5. Накопленная аудитория за 14 дней — 70,2 тыс. кук: 53,6 тыс. + новых и 16,6 тыс. возвратов предыстории. У 192 пар оба назначенных заказа + попали в снимок; остальные горизонт переживают. Числа считаются прогоном плана + без событий. Предыдущий замер пересчитан при #40; актуальные числа — здесь и в + таблице замера #50. +- **Числа дня** (#39, перемерено при #40, 2026-08-02): ≈1,4 визита на день + активности куки и ≈4,8 страницы на визит. Обычному посетителю дано 6% до + корзины, затем 45% и 55%; помеченному покупателю — 18% и 60%. Средний день: 9 + 879 визитов, 47 433 pageview, 4,80 страницы на визит, конверсия 2,4%. + +**Измерено после правки #50** (2026-08-02; канонический мир, 14 дней; правый +столбец — до правки). Доли по уровням и состав заказа считаются по всему окну, а +не как среднее дневных долей: около 240 заказов в дне дают слишком шумную долю. +Трафиковая половина не сдвинулась ни на строку. | величина | стало | было | |---|---|---| @@ -868,191 +844,108 @@ | брошено позиций в покупающих корзинах | 7,25% | 12,0% | Знаменатель «просмотров карточек» здесь — только просмотры страниц. При -постановке #50 в него попали и строки событий корзины: событие садится на -карточку и несёт её же страницу, отчего вышло 26 903 просмотра и 6,82% -вместо 25 067 и 7,32%. Величина одна и та же, счёт разный. +постановке #50 в него попали и строки событий корзины: событие несет ту же +страницу, отчего вышло 26 903 просмотра и 6,82% вместо 25 067 и 7,32%. Величина +одна и та же, счет разный. -Решено при исполнении #50 самой реализацией: +### Запуск, упаковка и приемники -- **Намерение визита берётся из шага воронки, а не из метки покупателя.** - Кладёт всякий визит, открывший карточку, но дошедший до `/cart` кладёт - почти всё, что сравнивал, а прочий — редко. Метка покупателя в это - решение не заходит, и не потому, что не влияет: она уже влияет — через - воронку, где помеченному дано 18% против 6%. Взять её вторым слагаемым - значило бы посчитать одно и то же дважды и протянуть план состава сквозь - шов, по которому день отдаёт торговой половине только страницы и товары. - Плата названа: у визита без намерения добавление ни с чем в человеке не - связано — упрощение, но не ложь, потому что склонные покупать и так - преобладают среди дошедших до `/cart`. -- **Два ряда вероятностей, а не ряд с множителем.** Уровень спроса читается - дважды: ряд для визита с намерением и ряд для того, кто смотрит. Один ряд - с множителем намерения не годится: вероятности визита с намерением обязаны - быть высокими (иначе его корзина схлопывается до одной позиции, а заказ до - одного товара), а у потолка в 100% разница уровней съедается, и конверсия - по уровням перестаёт различаться. Разрыв уровней внутри рядов поэтому - разный: у смотрящего товар решает почти всё, у пришедшего покупать — - заслонён намерением. -- **Доля брошенных позиций опущена с 15% до 10%.** Корзина сузилась с 2,4 - позиции до 1,7, и прежние 15% чаще выносили её целиком: спасённая позиция - возвращала заказ к одному товару, и однопозиционных заказов набиралось под - 60%. Свойство сохранено, число перемерено — ровно так, как этот блок и - оговаривал. -- **Ни одному уровню спроса нельзя дать нулевую вероятность.** Ноль у - залежавшегося товара выглядел заманчиво — он дешевле всего разводил - конверсию уровней, — но по товарам этого уровня возвращал ровно то - равенство, ради которого правка и делалась: событие корзины случалось бы - только внутри визита, открывшего `/cart`, без единого исключения на - четверти каталога. Отвязка либо про весь ассортимент, либо она не отвязка. - Разводить уровни поэтому приходится не нулём внизу второго ряда, а - ослаблением залежавшегося в первом: пришедший покупать берёт его реже - ходового. -- **Уровень спроса — колонка `demand` в конце файла каталога**, значения - словами («магнит», «обычный», «залёживается»). Раздан он одной колодой на - весь каталог: доли по категориям расходятся сами (магнитов от четырёх до - девяти на тридцать строк). Раскладывать колоду поровну в каждую категорию - было бы той же неправдоподобной ровностью, какую тикет вычищал из данных. - Связь с ценой отклонена выше, и тест сторожит её отсутствие — по четвертям - цены и по медиане, потому что одни только края списка обманываются - четырьмя исключениями. Хвост соседям: словарь ClickHouse над этим файлом - ещё не построен (#37/#43) — колонку надо взять в его описание сразу, а не - догонять правкой; описи нужен хеш каталога (хвост #42) тем более, - потому что теперь в файле живёт ещё и поведение. -- **Доля просмотров карточек, доходящих до корзины, выросла с 7,3% до 8,6% - и подошла к потолку вилки.** Иначе не сошлось: треть кладущих визитов вне - `/cart` требует своих позиций, а состав заказа требует, чтобы визит с - намерением клал почти всё сравненное. Обе границы тянут число вверх, и - опустить его — значит отпустить одну из них. Место до 9% невелико, и - следующая правка торговой половины считается против него. -- **Вилка 5–9% — ориентир, а не граница** (решение владельца, 2026-08-02). - Доля просмотров, доходящих до корзины, у живых магазинов гуляет широко: - пока число правдоподобно, само по себе оно ничего не сторожит, и - подгонять поведение под его край не надо. Настоящий предел здесь другой и - считается в другой валюте — дневной бюджет событий: события корзины - входят в те самые «около 50 тыс.», на которых стоит опись (#42). Упрётся - будущая правка — двигать надо бюджет и его причины, а не долю. +Первые два решения владелец принял на грилинге 7 августа 2026 года при #41; +остальные приняты при исполнении #41 (2026-08-07). -Решено при исполнении #41 (2026-08-07). Первые два пункта — решения владельца -грилингом 7 августа 2026 года, внесённые как есть; остальные приняты при -исполнении. - -- **Генератор живёт в своём контейнере**, и на этапе 5 даг будет запускать - контейнер, а не импортировать пакет в процессе воркера. Довод учебный: так - граница между оркестратором и задачей видна глазами — у задачи своё - окружение, свой образ, свой процесс, а оркестратор передаёт параметры и - читает результат. Это та же ступенька, с которой в бою вырастает - `KubernetesPodOperator`. Побочно это единственный вариант, при котором - «генератор — отдельная и заменяемая сущность» перестаёт быть словами: его - зависимости не смешиваются с окружением Airflow. Хостовый `uv` остаётся для - целей генератора — `make test`, `make lint` и `make typecheck` из каталога - `generator/`. Цена названа и принимается: - чтобы даг запускал контейнер, в Airflow пробрасывается сокет докера, а это - доступ, равный root на хосте; на учебном стенде размен допустим, но - записывается уроком — в бою так не делают. Отклонено: *генератор в образе - Airflow, даг импортирует пакет* (так у стенда v1) — механика проще, но менти - видит лишь «даг позвал функцию», а платить пришлось бы сменой базового - образа всего стенда, переездом контекста сборки и смешением зависимостей; - *`ExternalPythonOperator` — задача в отдельном venv внутри образа Airflow*: - изоляцию даёт и сокета не требует, но границу видно в конфигурации, а не в - `docker ps` — остаётся запасным путём, если проброс сокета однажды окажется - неприемлемым. -- **Клиент Kafka — `confluent-kafka`.** Он уже стоит в образе Airflow, на нём - написан пробник `dags/test_kafka.py`, и его же использует официальный - провайдер Kafka для Airflow: один клиент на стенде вместо двух. Объявлен - необязательной группой зависимостей генератора (`[kafka]`), а образ ставит - её из лока: приёмник импортирует клиента внутри функции, поэтому пакет без - этой группы остаётся живым и переносимым — с файловым приёмником. -- **Интерфейс запуска — `python -m clickstream_generator batch|live`.** - Зовущий — контейнер: параметры приходят аргументами или переменными - окружения (`GENERATOR_SEED`, `GENERATOR_DAY`, `GENERATOR_DAYS`, - `GENERATOR_LIMIT`, `GENERATOR_SPEED`, `GENERATOR_FILE`, - `KAFKA_BOOTSTRAP_SERVERS`, `KAFKA_TOPIC`), логи идут в стандартный вывод, - итог виден кодом возврата. Общее у режимов: `--seed`, `--day`, `--days` и - приёмник — `--file` либо `--brokers` с `--topic`. Состояния нет: позицию на - оси ведёт зовущий (хвост этапу 5 — раздел 8). `--days` играет несколько - дней подряд одним запуском — этим зальётся стартовый мир (#42), восемь - запусков службы внутри `make up` были бы плохим ответом. Отклонено: - *режимы флагом `--speed 0`* — различие тогда прячется за числом, тогда как - у режимов разное устройство: у пакетного есть пачка и нет ожидания, у - живого наоборот; *приёмник по умолчанию* — молчание однажды напишет в файл - то, чего ждали в Kafka, поэтому не назван приёмник — ошибка, названы оба — +- **Генератор живет в своем контейнере** (#41, 2026-08-07), и на этапе 5 даг + запускает контейнер, а не импортирует пакет в воркере. Так менти видит + отдельные окружение, образ и процесс задачи; зависимости не смешиваются с + Airflow. Это ступенька, из которой в бою вырастает `KubernetesPodOperator`, и + единственный вариант, где «генератор — отдельная и заменяемая сущность» виден + глазами. Хостовый `uv` остается для `make test`, `make lint` и `make + typecheck` из каталога `generator/`. Цена названа: сокет Docker в Airflow дает + доступ уровня root на хосте; для учебного стенда это принято, в бою так не + делают. Отклонено: *пакет в образе Airflow* — менти увидит лишь вызов функции, + а стенд заплатит сменой базового образа, контекста сборки и смешением + зависимостей; *`ExternalPythonOperator`* — изоляция есть и сокет не нужен, но + граница видна в конфигурации, а не в `docker ps`. Это запасной путь, если + сокет станет неприемлем. +- **Клиент Kafka — `confluent-kafka`** (#41, 2026-08-07). Он уже есть в образе + Airflow и пробнике `dags/test_kafka.py`, его использует официальный провайдер. + Клиент объявлен необязательной группой `[kafka]`, а приемник импортирует его + внутри функции, поэтому пакет живет и без Kafka — с файловым приемником. На + стенде остается один клиент вместо двух, а образ ставит его из файла + блокировки зависимостей. +- **Интерфейс запуска — `python -m clickstream_generator batch|live`** (#41, + 2026-08-07). Параметры приходят аргументами или переменными окружения + (`GENERATOR_SEED`, `GENERATOR_DAY`, `GENERATOR_DAYS`, `GENERATOR_LIMIT`, + `GENERATOR_SPEED`, `GENERATOR_FILE`, `KAFKA_BOOTSTRAP_SERVERS`, + `KAFKA_TOPIC`), логи идут в стандартный вывод, итог виден кодом возврата. + Общие параметры — `--seed`, `--day`, `--days` и приемник `--file` либо + `--brokers` с `--topic`. Состояния нет: позицию ведет зовущий (раздел 8). + `--days` играет несколько дней одним запуском, чтобы стартовый мир не требовал + восьми запусков службы. Отклонено: *режимы через `--speed 0`* — у пакетного + режима есть пачка без ожидания, у живого — ожидание без пачки, а флаг прячет + разное устройство за числом; *приемник по умолчанию* — молчание может написать + в файл то, чего ждали в Kafka. Не назвать приемник — ошибка, назвать оба — тоже. -- **Умолчание есть только у того, что описывает мир** (решение владельца при - разборе ревью #41, 2026-08-07). Зерно (каноническое), число дней (1) и темп - живого дня (×60) — свойства модели: они заданы спекой и этим тикетом, и - умолчание здесь — тот же самый ответ. Позиция на оси (`--day`), адрес - брокера, имя топика и путь файла — факты стенда, на котором генератор - запущен; своего умолчания у них нет, и без них прогон падает с ненулевым - кодом до первой сгенерированной строки. Довод: генератор — отдельная и - переносимая сущность, факты про *этот* стенд обязан приносить зовущий - (служба compose, цель `make`, даг этапа 5). Умолчание подменило бы забытый - параметр чужим фактом и выдало бы неверный ответ за успех — даг `next_day`, - не передавший день, переиграл бы нулевой, вышел бы с нулём, был бы записан - удачей и подвинул позицию: мир и позиция разъехались бы молча. Отличие от - `--days 1` названо прямо: единица измерения — минимальный *верный* ответ, а - `0` в позиции — конкретный *неверный*, поданный тихо. Переменные окружения - (`GENERATOR_DAY`, `KAFKA_TOPIC`) умолчаниями не считаются: это тот же - зовущий, только другим каналом. Следствие для обвязки: имя топика и день - служба compose и цели `make` передают явно. -- **Ограниченная пачка — флаг `--limit N` пакетного режима**: потолок событий - на весь прогон, а не на каждый день. Довод от менти: тот, кто хочет - посмотреть на конвейер, не должен ждать полсотни тысяч событий. Сочетается - с файловым приёмником, даёт взять срез, ещё не приезжавший в ODS, а число - отправленного печатается всегда — счёт нужен обеим сторонам (на нём стоят - проверки #43). На живой день пачка не распространяется: ожидание там - снимает ускорение. -- **Ключа у сообщения Kafka нет.** `WatchID` ключом был бы ключом только на - вид: обещание Kafka про ключ — «сообщения одного ключа лежат в одном - разделе и сохраняют порядок», а у ряда, где ключи не повторяются, обещать - нечего; читатель же прочёл бы в нём смысл, которого нет. Осмысленный ключ - здесь — `ClientID`, но раскладку по разделам стенд намеренно оставляет - транспорту (раздел 2), и лаба «какая нода читала топик» живёт как раз тем, - что раскладка не предрешена. Проверено 2026-08-07 (Context7 по - `CONFIGURATION.md` librdkafka и снятие умолчаний с самой библиотеки, - поставленной `confluent-kafka` 2.15.0, через `rd_kafka_conf_dump`): - умолчание топикового `partitioner` — `consistent_random`, то есть у - сообщения без ключа раздел случайный; `sticky.partitioning.linger.ms` по - умолчанию 10 — окно, в котором подряд идущие сообщения без ключа липнут к - одному разделу. Документация умолчаний не называет, поэтому оба числа взяты - у библиотеки. Следствие: день целиком идёт секунды и ложится в оба раздела, - а короткая пачка укладывается в одно окно и уезжает в один — поэтому «топик - прочитан обеими нодами» проверяется днём и разово. -- **Приёмников два, а режимов три.** «Kafka пачкой» и «Kafka с темпом ×60» — - один и тот же приёмник у разных зовущих: темп держит проигрыватель, знающий - время события. Приёмник, умеющий ускорение, снова знал бы о содержимом — - ровно то, чего правило «глупых приёмников» (раздел 4) не хочет. -- **Раскладка образа повторяет раскладку репозитория**: `/app` — корень, - пакет лежит исходником в `/app/generator/src` и виден через `PYTHONPATH`, - каталог товаров — в `/app/data`. Причина не в красоте: путь к каталогу - считается от модуля вверх по дереву (`catalog.py`, `parents[3]`), а - поставленный не editable пакет переехал бы в `site-packages` — и те же - `parents[3]` указали бы внутрь `.venv`. Тогда каталог в образе есть, а - модуль его не находит. Проверено запуском в контейнере; заодно день, - сыгранный в образе, совпал побайтово с днём, сыгранным на машине. +- **Умолчание есть только у того, что описывает мир** (#41, решение владельца + 2026-08-07). Каноническое зерно, `--days 1` и темп живого дня ×60 — свойства + модели: умолчание здесь означает тот же верный ответ. `--day`, адрес брокера, + топик и путь файла — факты стенда; без них прогон падает с ненулевым кодом до + первой строки. Генератор отдельный и переносимый, поэтому сведения об этом + стенде приносит зовущий — Compose, `make` или даг. Иначе забытый день мог бы + переиграть D0, выйти с успехом, подвинуть позицию и молча разойтись с миром. + Отличие от `--days 1` в том, что единица — минимальный верный ответ, а нулевой + день в забытом параметре — конкретный неверный. Переменные окружения — другой + канал зовущего, не умолчания; обвязка передает топик и день явно. +- **Ограниченная пачка — `--limit N` пакетного режима** (#41, 2026-08-07): + потолок событий на весь прогон. Она сочетается с файлом и дает быстро взять + срез, еще не приезжавший в ODS, для знакомства с конвейером. Менти не должен + ждать полный дневной поток, чтобы посмотреть путь данных. Число отправленного + печатается всегда и нужно проверкам #43. На живой день ограничение не + действует: ожидание там снимает ускорение. +- **Ключа у сообщения Kafka нет** (#41, 2026-08-07). Уникальный `WatchID` не + дает обещания порядка: сообщения с таким ключом не повторяются. Осмысленным + ключом был бы `ClientID`, но раскладку по разделам стенд намеренно оставляет + транспорту (раздел 2), и лаба о чтении топика живет тем, что она не + предрешена. Проверено 2026-08-07 по `CONFIGURATION.md` librdkafka и + `rd_kafka_conf_dump` библиотеки `confluent-kafka` 2.15.0: без ключа + `consistent_random` выбирает раздел, а `sticky.partitioning.linger.ms = 10` + временно прикрепляет соседние сообщения к одному разделу. Документация + умолчаний не называет, поэтому значения сняты с самой библиотеки. День идет + секунды и ложится в оба раздела, короткая пачка может уложиться в одно окно; + поэтому чтение обеими нодами проверяется полным днем и разово. +- **Приемников два, а режимов три** (#41, 2026-08-07). Kafka пачкой и Kafka с + темпом ×60 используют один приемник у разных зовущих; темп держит + проигрыватель, знающий время события. Приемник с ускорением снова знал бы о + содержимом — именно это запрещает правило глупых приемников из раздела 4. +- **Раскладка образа повторяет репозиторий** (#41, 2026-08-07): `/app` — корень, + пакет лежит в `/app/generator/src` и виден через `PYTHONPATH`, каталог — в + `/app/data`. Путь к каталогу считается от `catalog.py` вверх на `parents[3]`. + Обычная установка пакета перенесла бы модуль в `site-packages`, и тот же + подъем указал бы внутрь `.venv`: каталог был бы в образе, но модуль его не + нашел бы. Проверено в контейнере: день совпал побайтово с прогоном на машине. -Решено при исполнении #42 (2026-08-07): +### Опись мира и словарь -- **Опись мира одна, и она же растёт до эталонной.** Стартовый мир конца - этапа 2 — первые восемь дней оси, понедельник по понедельник (решение - владельца при нарезке, 2026-08-01): полная неделя с выходными и первый - замкнутый цикл окна K = 7. Отдельного «младшего» файла для них не - заводится: `data/world-inventory.json` — та самая опись, которую раздел 5 - обещает эталонному миру, пока короткая. Этап 7 продлит её до четырнадцати - дней, а не заведёт вторую. Форма — JSON: паспорт мира (зерно, версия - генератора, хеш каталога) и по строке на день с датой, числом событий и - хешем. Собирается целью `make inventory`, свежесть сторожит тест - `test_inventory.py` — тем же способом, что свежесть «описания выгрузки». - Отклонено: *два файла, «мини» и полный* — две правды об одном мире и - лишнее слово в словаре. -- **Хеш каталога — в паспорте, и работа у него объяснительная.** Правка - цены в `data/catalog/products.csv` меняет мир так же молча, как правка - кода, но поймают её и без хеша: хеши дней разойдутся. Хеш каталога - отвечает на следующий вопрос — **что** правили: разошлись дни и каталог — - CSV; разошлись только дни — код. -- **Слова.** «Манифест» по всей спеке переименован в **опись мира**, а - «зерновой мир» — в **стартовый мир** (решение владельца 2026-08-07). - Довод — правило языка репозитория: есть обычное русское слово — берётся - оно. Заодно исчезла пара «манифест / мини-манифест», в которой читателю - пришлось бы различать две сущности там, где вещь одна. +- **Опись мира одна и растет до эталонной** (#42, 2026-08-07; решение владельца + при нарезке, 2026-08-01). Стартовый мир этапа 2 — первые восемь дней, + понедельник по понедельник: полная неделя и первый замкнутый цикл K = 7. + `data/world-inventory.json` — та самая опись, которую обещает раздел 5, пока + короткая; этап 7 продлит ее до 14 дней, а не заведет вторую. В JSON лежат + паспорт мира — зерно, версия генератора и хеш каталога — и строки дней с + датой, числом событий и хешем. Опись собирает `make inventory`, свежесть + сторожит `test_inventory.py` тем же способом, что свежесть описания выгрузки. + Отклонено: *два файла, младший и полный* — две правды об одном мире и лишнее + слово в словаре. +- **Хеш каталога лежит в паспорте и объясняет расхождение** (#42, 2026-08-07). + Правка цены меняет мир так же молча, как правка кода. Хеши дней и без + каталожного хеша поймают смену мира; он отвечает на следующий вопрос — что + менялось: разошлись дни и каталог — CSV, только дни — код. +- **«Манифест» переименован в «опись мира», «зерновой мир» — в «стартовый мир»** + (#42, решение владельца 2026-08-07). Обычные русские слова убирают пару + «манифест / мини-манифест» и необходимость различать две сущности там, где + вещь одна. -Открытых вопросов за разделом не осталось: интерфейс запуска закрыт #41, -форма описи и хеш каталога — здесь. +### Открытое на момент заморозки + +- **`ParsedParamsKey1` остается пустой** (#39, 2026-08-02) — отложенное решение, + а не дыра. Вариант A/B-теста был бы постоянным свойством куки, а не поведением + дня; его заводит тикет #47.