docs(generator): заморожена спека этапа 2, решения — в ADR 0015-0017

- Зачем:
  - спека разрослась до 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 <noreply@anthropic.com>
This commit is contained in:
ddadminandClaude Fable 5 committed 2026-08-28 08:16:11 -04:00
1 parent 74061aa499
commit 6f18e9989c
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), а границы двух источников и склейки —
в [мастер-спеке стенда](../../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):
он отвечает на другой вопрос — как пройти принятое устройство по коду.
@@ -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.
+2 -2
View File
@@ -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).
+4 -3
View File
@@ -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).
+1 -1
View File
@@ -296,7 +296,7 @@ UTC+4), и пересчёт идёт один раз при наполнении
**День берётся из `EventDate`.** Дата в поясе счётчика уже посчитана
генератором и лежит колонкой, так что суточные срезы группируются по ней и
пояса не упоминают вовсе. Почему у ночных событий `toDate(UTCEventTime)` с ней
расходится — [спека генератора](../specs/2026-08-01-generator.md), раздел 9.
расходится, объясняет [ADR 0015](../adr/0015-counter-timezone.md).
## Путь реплицированных таблиц в keeper
+410 -517
View File
@@ -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/<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) — решения владельца до реализации.
Три решения выходят за границы тикета: правятся план состава (#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/<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) на грилинге «просмотр → корзина» нашли два
точных равенства. В живых данных таких равенств нет: аналитик прочитает
их как склейку разметки вместо поведения людей.
- **Торговый хвост разводит и соседние визиты, а не только сутки.** Правило
резки требует, чтобы визиты одной куки стояли дальше таймаута; считать это
расстояние от последней страницы, когда последним событием визита бывает
покупка, значило отдать таймауту торговый хвост — лаба сессий склеила бы
два визита в один и разошлась бы с `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.