Merge pull request 'docs(generator): заморозка спеки этапа 2 и ADR 0015-0017' (#181) from spec-freeze into main

Reviewed-on: #181
This commit was merged in pull request #181.
This commit is contained in:
ddmitry committed 2026-08-28 16:27:37 +03:00
commit c49f39f01f
9 files changed
+522 -526

No files matched your search

+36
View File
@@ -0,0 +1,36 @@
# ADR 0015. Модельный день считается в часовом поясе счетчика
Дата: 28 августа 2026 года. Статус: принято. Реализация — тикет #39.
## Решение
Модельные сутки считаются в часовом поясе счетчика UTC+4. `EventDate` обозначает
дату в этом поясе, а `UTCEventTime` — абсолютную метку времени. Граница
модельного дня приходится на полночь счетчика.
Суточная волна посетителя привязана к его местному времени. У гостя из другого
пояса она сдвигается на разницу с поясом счетчика, но общая граница дня остается
прежней.
## Почему
Так устроена выгрузка Метрики: дата события уже посчитана в поясе счетчика.
Полночь в этом поясе не разрезает утренние сессии в регионе мира. Регион можно
выбирать независимо от технического пояса счетчика.
Для всего потока дня нужна одна граница суток. Местное время гостя описывает
поведение в пределах этих суток, а отдельная дата для каждого посетителя не
нужна.
## Отклонено
В разделе 9 отклоненные варианты не зафиксированы.
## Следствия
У ночных событий `toDate(UTCEventTime) != EventDate`. Хранилище берет день из
готового `EventDate`, а не выводит его из абсолютной метки. Поворот суточной
волны слегка размывает общий пик активности гостей из разных поясов.
Решение принято при исполнении тикета #39 и зафиксировано в спеке этапа 2,
раздел 9.
+28
View File
@@ -0,0 +1,28 @@
# ADR 0016. Паспорт куки хранится в плане состава на всю ее жизнь
Дата: 28 августа 2026 года. Статус: принято. Реализация — тикет #39.
## Решение
Браузер, операционная система, устройство, экран и город принадлежат куке на всю
ее жизнь. План состава выбирает и хранит паспорт, а день только переносит его в
поля событий.
Две куки одного человека получают один город и разные устройства. Броски
паспорта идут после прежних бросков состава, поэтому прежние измерения не
сдвинулись.
## Почему
Кука представляет браузер на устройстве, поэтому ее паспорт не должен меняться
между днями. Только план состава связывает две куки с одним человеком. Поэтому
он может задать им один город и разные устройства.
## Отклонено
- Выводить паспорт арифметикой из `ClientID`: этот идентификатор не несет
сведений о двухкуковой паре, поэтому по нему нельзя задать двум кукам одного
человека один город и разные устройства.
Решение принято при исполнении тикета #39 и зафиксировано в спеке этапа 2,
раздел 9.
+34
View File
@@ -0,0 +1,34 @@
# ADR 0017. Номер заказа объединяет две проекции покупки
Дата: 28 августа 2026 года. Статус: принято. Реализация — тикет #40.
## Решение
Номер заказа включает день по модельному времени и порядковый номер покупки в
этот день, например `20260603-0042`. Событийная сторона пишет его в
`purchaseID`, бэкенд — в `order_id`; обе стороны берут один номер из плана
покупки.
Все покупки, попавшие в поток дня, нумеруются в порядке событий и до любых
потерь следующих этапов.
## Почему
По читаемому номеру сразу виден день заказа. Один номер соединяет две проекции
одной покупки. Генераторы кликстрима и бэкенда не согласуют его отдельно.
Присвоение до потерь сохраняет номера остальных заказов: исчезновение одного
события не должно перенумеровывать чужие покупки и разрывать связь с бэкендом.
## Отклонено
- Присваивать номера после внесения потерь: одна потеря сдвинула бы все
следующие номера за день и разорвала бы связь уже созданных проекций.
## Следствия
`purchaseID` и `order_id` изначально совпадают. Из-за расхождения событие может
исчезнуть после присвоения номера, но номера сохранившихся заказов не меняются.
Решение принято при исполнении тикета #40 и зафиксировано в спеке этапа 2,
раздел 9.
+4
View File
@@ -50,6 +50,10 @@ make demo
Устройство генератора и основания решений записаны в [спеке этапа Устройство генератора и основания решений записаны в [спеке этапа
2](../../specs/2026-08-01-generator.md), а границы двух источников и склейки — 2](../../specs/2026-08-01-generator.md), а границы двух источников и склейки —
в [мастер-спеке стенда](../../specs/2026-07-30-stand-v2-realism.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): [#48](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/48):
он отвечает на другой вопрос — как пройти принятое устройство по коду. он отвечает на другой вопрос — как пройти принятое устройство по коду.
@@ -23,7 +23,7 @@
сохраняет его на всю жизнь. сохраняет его на всю жизнь.
Броски паспорта стоят после прежнего состава когорты. Правило такого Броски паспорта стоят после прежнего состава когорты. Правило такого
продолжения подпотока описано в [дереве зерен](seeds.md), а основания выбора продолжения подпотока описано в [дереве зерен](seeds.md), а выбор
самого паспорта — в [спеке генератора](../../specs/2026-08-01-generator.md), паспорта объясняет [ADR 0016](../../adr/0016-cookie-passport.md). Зачем стенду
раздел 9. Зачем стенду нужна двухкуковая пара одного человека, фиксирует нужна двухкуковая пара одного человека, фиксирует
[мастер-спека](../../specs/2026-07-30-stand-v2-realism.md), раздел 5. [мастер-спека](../../specs/2026-07-30-stand-v2-realism.md), раздел 5.
+2 -2
View File
@@ -36,5 +36,5 @@
Как часовой пояс меняет представление времени в ClickHouse, описано в разделе Как часовой пояс меняет представление времени в ClickHouse, описано в разделе
«Часовые пояса» [документа о хранилище](../storage.md). Типы и публичный смысл «Часовые пояса» [документа о хранилище](../storage.md). Типы и публичный смысл
полей записаны в [описании события](../../formats/clickstream-event.md). полей записаны в [описании события](../../formats/clickstream-event.md).
Основание модели времени записано в [спеке Выбор модели времени объясняет
генератора](../../specs/2026-08-01-generator.md), раздел 9. [ADR 0015](../../adr/0015-counter-timezone.md).
+4 -3
View File
@@ -18,9 +18,10 @@
в трекере — не отдельная порода, а класс B, и делает его событийная сторона в трекере — не отдельная порода, а класс B, и делает его событийная сторона
выбрасыванием события после присвоения номера выбрасыванием события после присвоения номера
([классы расхождений](fate.md)); ([классы расхождений](fate.md));
- номер заказа общий у обеих проекций: `order_id` = клиентский `purchaseID`, - обе проекции используют один номер заказа: `order_id` = клиентский
читаемый номер «день и порядковый номер покупки» ([спека `purchaseID`;
генератора](../../specs/2026-08-01-generator.md), раздел 9); нумеруются все читаемый номер «день и порядковый номер покупки» описан в
[ADR 0017](../../adr/0017-order-numbering.md); нумеруются все
покупки, дошедшие до потока дня, — до всяких потерь; покупки, дошедшие до потока дня, — до всяких потерь;
- скидка заказа выводится из промокода события по таблице «код → скидка» — - скидка заказа выводится из промокода события по таблице «код → скидка» —
числу мира, которое этап 3 берёт готовым (спека генератора, разделы 8 и 9). числу мира, которое этап 3 берёт готовым (спека генератора, разделы 8 и 9).
+1 -1
View File
@@ -296,7 +296,7 @@ UTC+4), и пересчёт идёт один раз при наполнении
**День берётся из `EventDate`.** Дата в поясе счётчика уже посчитана **День берётся из `EventDate`.** Дата в поясе счётчика уже посчитана
генератором и лежит колонкой, так что суточные срезы группируются по ней и генератором и лежит колонкой, так что суточные срезы группируются по ней и
пояса не упоминают вовсе. Почему у ночных событий `toDate(UTCEventTime)` с ней пояса не упоминают вовсе. Почему у ночных событий `toDate(UTCEventTime)` с ней
расходится — [спека генератора](../specs/2026-08-01-generator.md), раздел 9. расходится, объясняет [ADR 0015](../adr/0015-counter-timezone.md).
## Путь реплицированных таблиц в keeper ## Путь реплицированных таблиц в keeper
+410 -517
View File
@@ -1,6 +1,8 @@
# Генератор (этап 2): функциональный мир, детерминизм до байта, контракт схемы, числа скорости # Генератор (этап 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. Дата: 2026-08-01. Мандат — тикет #14 (этап 2, родитель #4), карта #26.
Развилки: модель мира (#27), детерминизм от зерна (#28), архитектура вывода Развилки: модель мира (#27), детерминизм от зерна (#28), архитектура вывода
(#29), производительность (#30); исследование скорости (#31). (#29), производительность (#30); исследование скорости (#31).
@@ -502,354 +504,328 @@
## 9. Вопросы нарезки этапа 2 (#34): решения и остатки ## 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/<id>`, `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) — решения владельца до реализации. - **Каталог товаров заводится в #39, а не в #40** (#39, решение владельца до
Три решения выходят за границы тикета: правятся план состава (#38), воронка реализации, 2026-08-02). Карточке нужен `Title`, а имя товара живет в
(#39) и каталог товаров (#39). Сейчас это дёшево — опись ещё не собрана каталоге: без каталога #39 сделал бы лишь половину трафика.
(#42); позже обошлось бы её пересборкой. Часть решений принята после `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/<id>`,
`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) на грилинге «просмотр → корзина» нашли два
точных равенства. В живых данных таких равенств нет: аналитик прочитает
их как склейку разметки вместо поведения людей.
- **Торговый хвост разводит и соседние визиты, а не только сутки.** Правило - **Корзина шире заказа** (#40, решение владельца до реализации, 2026-08-02).
резки требует, чтобы визиты одной куки стояли дальше таймаута; считать это Посетитель кладет товары открытых карточек, а покупает не все. Иначе событие
расстояние от последней страницы, когда последним событием визита бывает корзины ничего не рассказывало бы сверх покупки: заказ был бы ее точной
покупка, значило отдать таймауту торговый хвост — лаба сессий склеила бы копией. Карточка, открытая дважды, дает одну позицию — повторный просмотр
два визита в один и разошлась бы с `VisitID`. Расстояние считается от означает раздумье, а не второй товар. Количество штук — отдельный бросок:
последнего события; поймано тестом сборки визитов. обычно одна, изредка две-три. Отклонено: *заказ всегда из одного товара* —
- **Полночь забирает подтверждение вместе с его покупкой.** Резерв суток список позиций как урок умирает, а на нем стоит единственный носитель навыка
сначала считался только для визитов с обещанным планом заказом, и у «вложенный JSON» (мастер-спека, раздел 2); *досыпать неоткрытые товары* —
вольной покупки оставалось окно в несколько секунд: страница ломается шов #39; *бросать много позиций* — большинство заказов схлопывается в
подтверждения дожила до потока, а событие покупки за полночь вышло. Это один товар. Доля брошенных позиций позже пересмотрена в #50.
была бы потеря на клиентской стороне — класс этапов 4 и 6, заведённый по - **Событие корзины садится на карточку товара** (#40, решение владельца до
недосмотру. Теперь запас под торговый хвост есть у всякой страницы реализации, 2026-08-02), событие покупки — на подтверждение. В жизни корзину
подтверждения: либо она уходит за границу суток вместе с покупкой, либо шлет кнопка на карточке, а при нескольких товарах события иначе не разложить
остаются обе. Это ровно то правило, которое блок выше называет словами по открытым карточкам.
«визит, у которого страница подтверждения срезана полуночью, покупки не - **Корзина отвязана от страницы `/cart`** (#40, 2026-08-02). До правки
даёт»; в каноническом мире числа от правки не сдвинулись — случай не множества визитов с событием корзины и со страницей `/cart` точно совпадали;
выпал ни разу за 14 дней. пересчитано при #50, актуальные числа — в таблице замера #50. Теперь положить
- **Задержка торгового события короче самой короткой паузы между может любой визит, открывший карточку; `/cart` остается шагом воронки, а заказ
страницами.** Тогда событие корзины не обгоняет страницу, на которой по-прежнему рисуется только после подтверждения. У визита на `/cart` всегда
посетитель нажал кнопку, и не выходит за полночь: за ним в том же визите есть хотя бы одна позиция — страница пустой корзины невозможна, — но открыть
всегда идёт страница, которая полночь пережила. корзину после добавления необязательно. Так в мире появляются те, кто положил
- **Номера торговых событий — из торгового подпотока.** `WatchID` строки товар и не открыл корзину, — массовый сюжет магазина. Ошибка прошла нарезку,
корзины и покупки рисует `COMMERCE`: возьми их у трафика, и правка потому что воронку #39 описали страницами, решение #40 — событиями, а вопрос
торгового поведения сдвинула бы трафиковые номера. Ряды двух подпотоков «какой визит кладет» заново не задали: страничное правило молча переехало на
проверяются на непересечение — обещание уникальности держит дедупликацию уровень событий.
при переигровке дня, и предполагать его нельзя. - **Намерение визита берется из шага воронки, а не из метки покупателя** (#50,
- **Вариант товара — справочник мира.** Сырой `ecommerce` несёт `variant`, 2026-08-02). Визит на `/cart` кладет почти все сравненное, прочий — редко.
которого в плоских колонках нет; строки живут в справочниках рядом с Метка уже влияет через воронку; второе слагаемое посчитало бы ее дважды и
устройствами и городами, а не сочиняются на месте. протянуло бы план состава через шов страниц и товаров. Плата: у визита без
намерения добавление не связано со свойством человека, но склонные покупать и
так преобладают среди дошедших до `/cart`.
- **Доля брошенных позиций опущена с 15% до 10%** (#50, 2026-08-02). Корзина
сузилась с 2,4 позиции до 1,7, и прежние 15% чаще выносили ее целиком:
спасенная позиция возвращала заказ к одному товару, и однопозиционных заказов
набиралось под 60%. Свойство сохранено, число перемерено — ровно так, как блок
#40 и оговаривал.
Решено при исполнении #40 после приёмки (2026-08-02) — грилинг тождества ### Спрос товара и границы калибровки
«просмотр → корзина». Приёмка нашла два точных равенства: каждое в живых
данных не встречается и потому читается аналитиком как склейка в разметке,
а не как поведение людей.
- **Корзина отвязана от страницы корзины.** Замерено до правки на всех 14 - **Класть или нет решает и товар, а не только визит** (#40, 2026-08-02). Раньше
днях канонического мира: множества «визиты с событием корзины» и «визиты внутри воронки клали все карточки, вне ее — ни одной: конверсия по sku давала
со страницей `/cart`» совпадали в точности — 828 и 828 на дне 2, и так чистый шум, и вопрос «какие товары кладут чаще» не имел ответа. Теперь в
каждый день без единого расхождения. Событие корзины каталоге три уровня спроса: магнит, обычный и залеживающийся, а их вероятности
возникало только у визита, дошедшего до воронки, то есть факт добавления — числа мира. Отклонено: *свой процент у каждого из 180 sku* — необоснованные
идеально предсказывался более поздним просмотром страницы. Теперь рукописные числа в механически дополняемом каталоге; *выводить спрос из цены*
положить может любой визит, открывший карточку; страница `/cart` — жесткая связь, в которой менти найдет только вложенное авторами правило.
остаётся шагом воронки и решается как прежде, а заказ по-прежнему - **Два ряда вероятностей, а не ряд с множителем** (#50, 2026-08-02): один для
рисуется только у визита, дошедшего до подтверждения. Оговорка, без визита с намерением, другой для смотрящего. У намеренного визита вероятности
которой появилась бы новая ложь вместо старой: у визита, дошедшего до высоки, иначе корзина и заказ схлопываются до одного товара; возле 100%
`/cart`, хотя бы одна позиция в корзине есть всегда — страница корзины множитель съел бы разницу уровней и сделал бы конверсию уровней одинаковой.
при пустой корзине невозможна. Брошенные корзины в мире были и до правки Разрыв внутри рядов разный: у смотрящего товар решает почти все, а у
(в том же дне 828 корзин против 272 покупок); правка добавляет к ним пришедшего покупать заслонен намерением.
тех, кто положил и корзину не открывал, — самый массовый сюжет магазина. - **Ни одному уровню спроса нельзя дать нулевую вероятность** (#50, 2026-08-02).
Почему это проехало при нарезке: воронка #39 записана в терминах Ноль у залежавшегося товара вернул бы исходное равенство на четверти каталога:
страниц, #40 принял решение про события («событие корзины садится на добавление случалось бы только внутри визита с `/cart`. Отвязка либо относится
карточку»), но вопрос «какой визит вообще кладёт» заново не задал — ко всему ассортименту, либо это не отвязка. Поэтому уровни разводятся не нулем
страничное правило молча переехало на уровень событий. внизу второго ряда, а ослаблением залежавшегося товара в ряду визита с
- **Класть или нет решает и товар, а не только визит.** До правки в намерением: пришедший покупать берет его реже ходового.
корзину ложились все карточки покупающего визита: внутри воронки 100%, - **Уровень спроса — колонка `demand` в конце файла каталога** (#50,
вне её 0%. Привлекательность товара измерить было нечем — конверсия 2026-08-02), значения словами («магнит», «обычный», «залёживается»). Раздан он
«просмотр → корзина» по sku давала чистый шум, а вопрос «какие товары одной колодой на весь каталог: доли по категориям расходятся сами (магнитов от
кладут чаще» ответа не имел. Теперь у товара есть уровень спроса: четырёх до девяти на тридцать строк). Раскладывать колоду поровну в каждую
колонка каталога с тремя значениями (магнит, обычный, залёживается), а категорию было бы той же неправдоподобной ровностью, какую тикет вычищал из
вероятности трёх уровней — числа мира рядом с остальными. Отклонено: данных. Связь с ценой отклонена выше, и тест сторожит её отсутствие — по
*свой процент у каждого из 180 sku* — рукописные числа, которых никто не четвертям цены и по медиане, потому что одни только края списка обманываются
обоснует, а каталог по решению #39 дописывается механически; *выводить четырьмя исключениями. Хвост соседям: словарь ClickHouse над этим файлом ещё
склонность из цены* — жёсткая связь, в которой менти найдёт ровно то, не построен (#37/#43) — колонку надо взять в его описание сразу, а не догонять
что мы в неё вложили, и ничего сверх. правкой; описи нужен хеш каталога (хвост #42) тем более, потому что теперь в
- **Границы правки — те же вилки раздела 5**: аудитория 6–8 тыс., средний файле живёт ещё и поведение.
день около 50 тыс. событий, конверсия визита около 2%. Заказ рисуется из - **Доля просмотров карточек, доходящих до корзины, выросла с 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% | | брошено позиций в покупающих корзинах | 7,25% | 12,0% |
Знаменатель «просмотров карточек» здесь — только просмотры страниц. При Знаменатель «просмотров карточек» здесь — только просмотры страниц. При
постановке #50 в него попали и строки событий корзины: событие садится на постановке #50 в него попали и строки событий корзины: событие несет ту же
карточку и несёт её же страницу, отчего вышло 26 903 просмотра и 6,82% страницу, отчего вышло 26 903 просмотра и 6,82% вместо 25 067 и 7,32%. Величина
вместо 25 067 и 7,32%. Величина одна и та же, счёт разный. одна и та же, счет разный.
Решено при исполнении #50 самой реализацией: ### Запуск, упаковка и приемники
- **Намерение визита берётся из шага воронки, а не из метки покупателя.** Первые два решения владелец принял на грилинге 7 августа 2026 года при #41;
Кладёт всякий визит, открывший карточку, но дошедший до `/cart` кладёт остальные приняты при исполнении #41 (2026-08-07).
почти всё, что сравнивал, а прочий — редко. Метка покупателя в это
решение не заходит, и не потому, что не влияет: она уже влияет — через
воронку, где помеченному дано 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). Упрётся
будущая правка — двигать надо бюджет и его причины, а не долю.
Решено при исполнении #41 (2026-08-07). Первые два пункта — решения владельца - **Генератор живет в своем контейнере** (#41, 2026-08-07), и на этапе 5 даг
грилингом 7 августа 2026 года, внесённые как есть; остальные приняты при запускает контейнер, а не импортирует пакет в воркере. Так менти видит
исполнении. отдельные окружение, образ и процесс задачи; зависимости не смешиваются с
Airflow. Это ступенька, из которой в бою вырастает `KubernetesPodOperator`, и
- **Генератор живёт в своём контейнере**, и на этапе 5 даг будет запускать единственный вариант, где «генератор — отдельная и заменяемая сущность» виден
контейнер, а не импортировать пакет в процессе воркера. Довод учебный: так глазами. Хостовый `uv` остается для `make test`, `make lint` и `make
граница между оркестратором и задачей видна глазами — у задачи своё typecheck` из каталога `generator/`. Цена названа: сокет Docker в Airflow дает
окружение, свой образ, свой процесс, а оркестратор передаёт параметры и доступ уровня root на хосте; для учебного стенда это принято, в бою так не
читает результат. Это та же ступенька, с которой в бою вырастает делают. Отклонено: *пакет в образе Airflow* — менти увидит лишь вызов функции,
`KubernetesPodOperator`. Побочно это единственный вариант, при котором а стенд заплатит сменой базового образа, контекста сборки и смешением
«генератор — отдельная и заменяемая сущность» перестаёт быть словами: его зависимостей; *`ExternalPythonOperator`* — изоляция есть и сокет не нужен, но
зависимости не смешиваются с окружением Airflow. Хостовый `uv` остаётся для граница видна в конфигурации, а не в `docker ps`. Это запасной путь, если
целей генератора — `make test`, `make lint` и `make typecheck` из каталога сокет станет неприемлем.
`generator/`. Цена названа и принимается: - **Клиент Kafka — `confluent-kafka`** (#41, 2026-08-07). Он уже есть в образе
чтобы даг запускал контейнер, в Airflow пробрасывается сокет докера, а это Airflow и пробнике `dags/test_kafka.py`, его использует официальный провайдер.
доступ, равный root на хосте; на учебном стенде размен допустим, но Клиент объявлен необязательной группой `[kafka]`, а приемник импортирует его
записывается уроком — в бою так не делают. Отклонено: *генератор в образе внутри функции, поэтому пакет живет и без Kafka — с файловым приемником. На
Airflow, даг импортирует пакет* (так у стенда v1) — механика проще, но менти стенде остается один клиент вместо двух, а образ ставит его из файла
видит лишь «даг позвал функцию», а платить пришлось бы сменой базового блокировки зависимостей.
образа всего стенда, переездом контекста сборки и смешением зависимостей; - **Интерфейс запуска — `python -m clickstream_generator batch|live`** (#41,
*`ExternalPythonOperator` — задача в отдельном venv внутри образа Airflow*: 2026-08-07). Параметры приходят аргументами или переменными окружения
изоляцию даёт и сокета не требует, но границу видно в конфигурации, а не в (`GENERATOR_SEED`, `GENERATOR_DAY`, `GENERATOR_DAYS`, `GENERATOR_LIMIT`,
`docker ps` — остаётся запасным путём, если проброс сокета однажды окажется `GENERATOR_SPEED`, `GENERATOR_FILE`, `KAFKA_BOOTSTRAP_SERVERS`,
неприемлемым. `KAFKA_TOPIC`), логи идут в стандартный вывод, итог виден кодом возврата.
- **Клиент Kafka — `confluent-kafka`.** Он уже стоит в образе Airflow, на нём Общие параметры — `--seed`, `--day`, `--days` и приемник `--file` либо
написан пробник `dags/test_kafka.py`, и его же использует официальный `--brokers` с `--topic`. Состояния нет: позицию ведет зовущий (раздел 8).
провайдер Kafka для Airflow: один клиент на стенде вместо двух. Объявлен `--days` играет несколько дней одним запуском, чтобы стартовый мир не требовал
необязательной группой зависимостей генератора (`[kafka]`), а образ ставит восьми запусков службы. Отклонено: *режимы через `--speed 0`* — у пакетного
её из лока: приёмник импортирует клиента внутри функции, поэтому пакет без режима есть пачка без ожидания, у живого — ожидание без пачки, а флаг прячет
этой группы остаётся живым и переносимым — с файловым приёмником. разное устройство за числом; *приемник по умолчанию* — молчание может написать
- **Интерфейс запуска — `python -m clickstream_generator batch|live`.** в файл то, чего ждали в Kafka. Не назвать приемник — ошибка, назвать оба —
Зовущий — контейнер: параметры приходят аргументами или переменными
окружения (`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, решение владельца
разборе ревью #41, 2026-08-07). Зерно (каноническое), число дней (1) и темп 2026-08-07). Каноническое зерно, `--days 1` и темп живого дня ×60 — свойства
живого дня (×60) — свойства модели: они заданы спекой и этим тикетом, и модели: умолчание здесь означает тот же верный ответ. `--day`, адрес брокера,
умолчание здесь — тот же самый ответ. Позиция на оси (`--day`), адрес топик и путь файла — факты стенда; без них прогон падает с ненулевым кодом до
брокера, имя топика и путь файла — факты стенда, на котором генератор первой строки. Генератор отдельный и переносимый, поэтому сведения об этом
запущен; своего умолчания у них нет, и без них прогон падает с ненулевым стенде приносит зовущий — Compose, `make` или даг. Иначе забытый день мог бы
кодом до первой сгенерированной строки. Довод: генератор — отдельная и переиграть D0, выйти с успехом, подвинуть позицию и молча разойтись с миром.
переносимая сущность, факты про *этот* стенд обязан приносить зовущий Отличие от `--days 1` в том, что единица — минимальный верный ответ, а нулевой
(служба compose, цель `make`, даг этапа 5). Умолчание подменило бы забытый день в забытом параметре — конкретный неверный. Переменные окружения — другой
параметр чужим фактом и выдало бы неверный ответ за успех — даг `next_day`, канал зовущего, не умолчания; обвязка передает топик и день явно.
не передавший день, переиграл бы нулевой, вышел бы с нулём, был бы записан - **Ограниченная пачка — `--limit N` пакетного режима** (#41, 2026-08-07):
удачей и подвинул позицию: мир и позиция разъехались бы молча. Отличие от потолок событий на весь прогон. Она сочетается с файлом и дает быстро взять
`--days 1` названо прямо: единица измерения — минимальный *верный* ответ, а срез, еще не приезжавший в ODS, для знакомства с конвейером. Менти не должен
`0` в позиции — конкретный *неверный*, поданный тихо. Переменные окружения ждать полный дневной поток, чтобы посмотреть путь данных. Число отправленного
(`GENERATOR_DAY`, `KAFKA_TOPIC`) умолчаниями не считаются: это тот же печатается всегда и нужно проверкам #43. На живой день ограничение не
зовущий, только другим каналом. Следствие для обвязки: имя топика и день действует: ожидание там снимает ускорение.
служба compose и цели `make` передают явно. - **Ключа у сообщения Kafka нет** (#41, 2026-08-07). Уникальный `WatchID` не
- **Ограниченная пачка — флаг `--limit N` пакетного режима**: потолок событий дает обещания порядка: сообщения с таким ключом не повторяются. Осмысленным
на весь прогон, а не на каждый день. Довод от менти: тот, кто хочет ключом был бы `ClientID`, но раскладку по разделам стенд намеренно оставляет
посмотреть на конвейер, не должен ждать полсотни тысяч событий. Сочетается транспорту (раздел 2), и лаба о чтении топика живет тем, что она не
с файловым приёмником, даёт взять срез, ещё не приезжавший в ODS, а число предрешена. Проверено 2026-08-07 по `CONFIGURATION.md` librdkafka и
отправленного печатается всегда — счёт нужен обеим сторонам (на нём стоят `rd_kafka_conf_dump` библиотеки `confluent-kafka` 2.15.0: без ключа
проверки #43). На живой день пачка не распространяется: ожидание там `consistent_random` выбирает раздел, а `sticky.partitioning.linger.ms = 10`
снимает ускорение. временно прикрепляет соседние сообщения к одному разделу. Документация
- **Ключа у сообщения Kafka нет.** `WatchID` ключом был бы ключом только на умолчаний не называет, поэтому значения сняты с самой библиотеки. День идет
вид: обещание Kafka про ключ — «сообщения одного ключа лежат в одном секунды и ложится в оба раздела, короткая пачка может уложиться в одно окно;
разделе и сохраняют порядок», а у ряда, где ключи не повторяются, обещать поэтому чтение обеими нодами проверяется полным днем и разово.
нечего; читатель же прочёл бы в нём смысл, которого нет. Осмысленный ключ - **Приемников два, а режимов три** (#41, 2026-08-07). Kafka пачкой и Kafka с
здесь — `ClientID`, но раскладку по разделам стенд намеренно оставляет темпом ×60 используют один приемник у разных зовущих; темп держит
транспорту (раздел 2), и лаба «какая нода читала топик» живёт как раз тем, проигрыватель, знающий время события. Приемник с ускорением снова знал бы о
что раскладка не предрешена. Проверено 2026-08-07 (Context7 по содержимом — именно это запрещает правило глупых приемников из раздела 4.
`CONFIGURATION.md` librdkafka и снятие умолчаний с самой библиотеки, - **Раскладка образа повторяет репозиторий** (#41, 2026-08-07): `/app` — корень,
поставленной `confluent-kafka` 2.15.0, через `rd_kafka_conf_dump`): пакет лежит в `/app/generator/src` и виден через `PYTHONPATH`, каталог — в
умолчание топикового `partitioner` — `consistent_random`, то есть у `/app/data`. Путь к каталогу считается от `catalog.py` вверх на `parents[3]`.
сообщения без ключа раздел случайный; `sticky.partitioning.linger.ms` по Обычная установка пакета перенесла бы модуль в `site-packages`, и тот же
умолчанию 10 — окно, в котором подряд идущие сообщения без ключа липнут к подъем указал бы внутрь `.venv`: каталог был бы в образе, но модуль его не
одному разделу. Документация умолчаний не называет, поэтому оба числа взяты нашел бы. Проверено в контейнере: день совпал побайтово с прогоном на машине.
у библиотеки. Следствие: день целиком идёт секунды и ложится в оба раздела,
а короткая пачка укладывается в одно окно и уезжает в один — поэтому «топик
прочитан обеими нодами» проверяется днём и разово.
- **Приёмников два, а режимов три.** «Kafka пачкой» и «Kafka с темпом ×60» —
один и тот же приёмник у разных зовущих: темп держит проигрыватель, знающий
время события. Приёмник, умеющий ускорение, снова знал бы о содержимом —
ровно то, чего правило «глупых приёмников» (раздел 4) не хочет.
- **Раскладка образа повторяет раскладку репозитория**: `/app` — корень,
пакет лежит исходником в `/app/generator/src` и виден через `PYTHONPATH`,
каталог товаров — в `/app/data`. Причина не в красоте: путь к каталогу
считается от модуля вверх по дереву (`catalog.py`, `parents[3]`), а
поставленный не editable пакет переехал бы в `site-packages` — и те же
`parents[3]` указали бы внутрь `.venv`. Тогда каталог в образе есть, а
модуль его не находит. Проверено запуском в контейнере; заодно день,
сыгранный в образе, совпал побайтово с днём, сыгранным на машине.
Решено при исполнении #42 (2026-08-07): ### Опись мира и словарь
- **Опись мира одна, и она же растёт до эталонной.** Стартовый мир конца - **Опись мира одна и растет до эталонной** (#42, 2026-08-07; решение владельца
этапа 2 — первые восемь дней оси, понедельник по понедельник (решение при нарезке, 2026-08-01). Стартовый мир этапа 2 — первые восемь дней,
владельца при нарезке, 2026-08-01): полная неделя с выходными и первый понедельник по понедельник: полная неделя и первый замкнутый цикл K = 7.
замкнутый цикл окна K = 7. Отдельного «младшего» файла для них не `data/world-inventory.json` — та самая опись, которую обещает раздел 5, пока
заводится: `data/world-inventory.json` — та самая опись, которую раздел 5 короткая; этап 7 продлит ее до 14 дней, а не заведет вторую. В JSON лежат
обещает эталонному миру, пока короткая. Этап 7 продлит её до четырнадцати паспорт мира — зерно, версия генератора и хеш каталога — и строки дней с
дней, а не заведёт вторую. Форма — JSON: паспорт мира (зерно, версия датой, числом событий и хешем. Опись собирает `make inventory`, свежесть
генератора, хеш каталога) и по строке на день с датой, числом событий и сторожит `test_inventory.py` тем же способом, что свежесть описания выгрузки.
хешем. Собирается целью `make inventory`, свежесть сторожит тест Отклонено: *два файла, младший и полный* — две правды об одном мире и лишнее
`test_inventory.py` — тем же способом, что свежесть «описания выгрузки». слово в словаре.
Отклонено: *два файла, «мини» и полный* — две правды об одном мире и - **Хеш каталога лежит в паспорте и объясняет расхождение** (#42, 2026-08-07).
лишнее слово в словаре. Правка цены меняет мир так же молча, как правка кода. Хеши дней и без
- **Хеш каталога — в паспорте, и работа у него объяснительная.** Правка каталожного хеша поймают смену мира; он отвечает на следующий вопрос — что
цены в `data/catalog/products.csv` меняет мир так же молча, как правка менялось: разошлись дни и каталог — CSV, только дни — код.
кода, но поймают её и без хеша: хеши дней разойдутся. Хеш каталога - **«Манифест» переименован в «опись мира», «зерновой мир» — в «стартовый мир»**
отвечает на следующий вопрос — **что** правили: разошлись дни и каталог — (#42, решение владельца 2026-08-07). Обычные русские слова убирают пару
CSV; разошлись только дни — код. «манифест / мини-манифест» и необходимость различать две сущности там, где
- **Слова.** «Манифест» по всей спеке переименован в **опись мира**, а вещь одна.
«зерновой мир» — в **стартовый мир** (решение владельца 2026-08-07).
Довод — правило языка репозитория: есть обычное русское слово — берётся
оно. Заодно исчезла пара «манифест / мини-манифест», в которой читателю
пришлось бы различать две сущности там, где вещь одна.
Открытых вопросов за разделом не осталось: интерфейс запуска закрыт #41, ### Открытое на момент заморозки
форма описи и хеш каталога — здесь.
- **`ParsedParamsKey1` остается пустой** (#39, 2026-08-02) — отложенное решение,
а не дыра. Вариант A/B-теста был бы постоянным свойством куки, а не поведением
дня; его заводит тикет #47.