# Генератор (этап 2): функциональный мир, детерминизм до байта, контракт схемы, числа скорости Статус: Accepted — принята владельцем 2026-08-01 (PR #35). Дата: 2026-08-01. Мандат — тикет #14 (этап 2, родитель #4), карта #26. Развилки: модель мира (#27), детерминизм от зерна (#28), архитектура вывода (#29), производительность (#30); исследование скорости (#31). Источники: мастер-спека [«Боевой реализм стенда (v2)»](2026-07-30-stand-v2-realism.md) — разделы 1.4, 5, 9–11; заметка [«Скорость батчевой генерации в Python»](../research/2026-08-01-python-batch-generation-speed.md). ## Зачем Мастер-спека решила, **что** генерирует стенд: широкое событие в 47 колонок, таксономию, анонимность, двухкуковых покупателей, заказы слепками. Четыре развилки о том, **как** генератор устроен, она отложила: модель мира, границы детерминизма, источник истины схемы с разделением потока и пакета, числовые требования скорости. Карта #26 эти развилки прошла; спека собирает решения в одну картину. По ней этап 2 режется на тикеты (#34). Здесь не переоткрывается решённое мастер-спекой: модель данных события, таксономия, анонимность, механика заказов (границы мандата #14). Генератор слепков заказов — этап 3: эта спека лишь не должна ему мешать. Один осознанный выход за границы: решение о хранении снимка (раздел 5) формально касается этапа 7 — расширение подтверждено владельцем в резолюции «Производительности». ## Целевая картина одним взглядом - **Мир — функция, не состояние.** Состав мира — чистая функция зерна; модельный день D — функция (зерно, D). Между прогонами живут только зерно и позиция на оси времени. - **Детерминизм до байта.** Одно зерно — побайтово тот же снимок; сверка — хешами манифеста. Транспорт (офсеты Kafka, темп) — вне обещания. - **Схема — контракт генератора.** Python-модуль с чистыми данными; хранилище строится по рендеренной документации, границу сторожит строгий приём на стороне хранилища. - **Один сериализатор, глупые приёмники.** День-функция выдаёт канонические байты; приёмники — файл, Kafka пачкой, Kafka с темпом. - **Числа.** Средний день ~50 тыс. событий; эталонный снимок — 14 дней; в git — только манифест; автоматический порог один — день ≤ 30 с. ## 1. Модель мира Резолюция развилки [«Модель мира»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/27). - **Мир функциональный, ничего не мутирует.** Постоянный состав мира — популяция посетителей, их привычки, календарь двухкуковых пар — чистая функция зерна; целиком не вычисляется и не хранится, спрашивается по дням (форма плана — ниже). День D — функция (зерно, D); межднёвные связи (окно заказов K, опоздания) выводятся из плана состава, а не копятся в состоянии. Глобальные инварианты («каждый двухкуковый покупатель заказал с обеих кук») гарантируются планом; счётчики состава — посетители, пары, приток — известны до генерации событий, торговые счётчики сложатся, когда торговое поведение определит #40. - **Состав не замкнут: посетители появляются и затухают.** План состава задаёт календарь появления — у каждого посетителя есть дата первого дня и профиль возвратов, включая затухание: заметная доля кук одноразовая, как в живом трафике. Новые посетители появляются на всём протяжении оси: uniq(ClientID) растёт с горизонтом, дневная и накопленная аудитории не сходятся в одно число. Приток — часть плана, а не мутация: счётчики состава по-прежнему известны до генерации (уточнение по вычитке владельца, 2026-08-01); его числа — раздел 9. - **Форма плана — ленивая, по когортам дня** (уточнение при исполнении #38, 2026-08-02). Глобальный список посетителей не строится: когорта дня D — функция (зерно, D), её случайность — подпоток состава мира, ветвящийся по номеру дня (позиция в дереве — раздел 2), отдельный от подпотока дня-функции. Аудитория дня — когорта D плюс возвраты когорт последних дней: окно активности — хвост возвратов (константа мира — раздел 9), отсчитанный от первого дня человека и общий на обе его куки (уточнение при исполнении #38, 2026-08-02: окно от рождения каждой куки растянуло бы жизнь когорты вдвое, а с ней и загляд назад, которым ленивая форма и держится). За краем окна кука не возвращается, а профиль возвратов затухает к краю, поэтому обрыв в данных не виден. Стоимость дня не зависит от прожитого — день 500 стоит как день 5, горизонт не является входом. Счётчики состава считаются прогоном плана по дням, без генерации событий. - **Предыстория: полка с первого дня.** Когорты существуют и до D0 — на глубину хвоста возвратов. Событий они не порождают (ось событий начинается в D0) — только дают, кому возвращаться в первые дни: дневная аудитория на полке с самого D0, разгона «пустого магазина» нет. - **Гарантия двухкуковых пар — назначенные заказы в плане.** Единица здесь — человек, не кука: план помечает часть людей покупателями (доля — раздел 9), и 15% покупателей (мастер-спека, раздел 5) получают вторую куку. Обе куки пары принадлежат одному человеку одной когорты; вторая рождается в пределах его окна активности, без фиксированного зазора — по тому же затухающему профилю, что и возвраты (уточнение при исполнении #38, 2026-08-02): куку человек заводит, пока ещё ходит. Равные шансы по всему окну означали бы вторую куку у давно ушедшего человека — и вчетверо меньше пар, реализованных внутри снимка. Каждой паре план назначает дни гарантированных заказов: по одному на куку, из дней активности этой куки, на оси от D0 и позже; человеку предыстории, чьё окно активности таких дней не оставляет, пара не назначается. День-функция обязана назначенные заказы реализовать; остальные покупки — вольные, их решает генератор торговых событий (#40). Манифест считает пары, реализованные в горизонте снимка. Условность в данных не видна: дни назначены той же случайностью, просто брошенной планом один раз. - **Своя ось модельного времени.** Ось событий начинается в фиксированный день D0 (понедельник — см. раздел 5); реальный календарь в модели не участвует. В `EventDate`/`UTCEventTime` дни оси ложатся конкретными датами, но это константа мира, от даты запуска не зависящая (значение — раздел 9). Между прогонами живут только зерно и позиция на оси: выключенный ноутбук — мир замер, потом продолжил. - **Два режима движения по одной оси.** Пошаговый — базовый для лаб: старт с эталонного снимка, дальше «прожить следующий день» — явное действие. Живой день — текущий день проигрывается с ускорением, дашборд и мониторинг «дышат»; включается по требованию, не постоянный фон. - **Граница суток — единственный структурный шов.** Сессии режутся по ней, дневная партиция самодостаточна; в конце модельного дня — слепок заказов. День проживается целиком, полдня не бывает: недожитый из-за обрыва день переигрывается (раздел 4). - **Поток и пакет совместимы по построению.** День-функция выдаёт один упорядоченный поток событий; режимы отличаются только способом проигрывания — пачкой или с темпом. Отклонено с доводами: - *Мутирующее состояние мира* («мир стареет»): ломает параллельность по дням, требует чекпоинтов, счётчики манифеста узнаваемы только постфактум; ни один урок стенда на старении не стоит. - *Чистая функция без слоя состава*: глобальные инварианты пришлось бы выводить в каждом дне заново — тот же план мира, но неявный и размазанный. - *Привязка модельного времени к реальному календарю* (T-1 с догоном): конфликтует с ускорением ×60 — за вечер мир уезжает в будущее — и делает даты эталонного мира зависимыми от даты запуска, манифест теряет воспроизводимость. Отклонено при исполнении #38 (2026-08-02): - *Разгон вместо предыстории* («первые дни малы — магазин запустился»): зерновой мир и половина снимка оказались бы на разгоне, недельная лаба сравнивала бы несравнимые недели, «средний день ~50 тыс.» перестал бы быть средним — пришлось бы двигать принятые числа раздела 5. - *Материализованный план на горизонт*: горизонт становится обязательным входом каждого запуска, стоимость старта растёт с прожитым; префиксную устойчивость даёт и ленивая форма — даром, через подпотоки по номеру дня. - *Замкнутый пул кук со сменой поколений*: постоянная память ценой фальшивой константы — потолка одновременно живущих кук, которого в жизни нет и который менти нечем объяснить. - *Вероятностная гарантия пар* («почти наверняка купит с обеих кук»): не гарантия — однажды манифест покраснеет, а чинить нечем, кроме смены зерна; при этом несклеенная пара в данных неотличима от двух незнакомцев, так что реализм этой лотереи невидим. - *Покупка пары в первый визит куки*: гарантия железная и дешёвая, но узор «все пары покупают в первый день» виден в данных ровно там, куда лаба склейки смотрит пристальнее всего. Условность допустима, пока она не видна в данных. ## 2. Детерминизм от зерна Резолюция развилки [«Детерминизм от зерна»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/28). - **Обещание — содержимое до байта.** Два прогона с одним зерном дают тот же набор событий: те же `WatchID`/`VisitID`, поля, метки модельного времени. Снимок при пересборке побайтово совпадает: канонический порядок ключей и строк; сверка — по хешам манифеста, а два локально пересобранных снимка сравнимы обычным diff — пустой означает «ничего не изменилось». Вне обещания — транспорт: офсеты и партиции Kafka, какая нода прочитала, `_load_ts`, темп живого дня. - **Условия обещания.** Детерминизм держится при зафиксированном `uv.lock` и внутри канонического контейнера — то есть везде Linux, на маке и в WSL тоже; единственная переменная — архитектура CPU. Истина — CI на Linux; сходимость любой машины проверяет скрипт «пересгенерируй день N — сравни хеш с манифестом». Расхождение на любой платформе — баг генератора, а не допуск. - **Раздача зерна — иерархией подпотоков.** Корневое зерно → подпоток состава мира, ветвящийся по номеру дня на когорты плана (состав спрашивается по дням — раздел 1; уточнение при исполнении #38). Дни предыстории отрицательны, а позиция в дереве — неотрицательное целое, поэтому у предыстории своя ветвь состава, отдельная от оси (уточнение при исполнении #38, 2026-08-02); (зерно, день) → подпоток дня → именованные подпотоки компонентов: трафик, торговые события, расхождения, опоздания — в фиксированном порядке. По построению: параллельный прогон равен последовательному; продление истории днём N+1 не трогает дни 1…N; правка одного компонента меняет только его часть снимка — в манифесте меняются хеши только затронутых дней, дифф двух локальных пересборок читаем. - **Механизм подпотоков — `numpy.random.SeedSequence`.** Сверено через Context7 по документации numpy (2026-08-01): `spawn(n)` порождает детей расширением `spawn_key`, потомок полностью определяется парой (entropy, spawn_key) — позицией в дереве, а не порядком вычислений. Это ровно то свойство, на котором держатся три гарантии предыдущего пункта. Каждый подпоток кормит `PCG64` — генератор, рекомендованный numpy. - **Дисциплина целочисленной случайности.** Случайность тянется целыми числами: диапазоны, выбор из таблиц. Плавающие распределения из системной математики не используются — это снимает межархитектурные расхождения amd64/arm64. Деньги считаются в целых копейках; Float64 — только представление в клиентском `purchase` (урок мастер-спеки о расхождениях). - **Канонический seed и паспорт мира.** Эталонный мир собирается одним каноническим зерном — константой репозитория; свои зёрна менти крутит без гарантий манифеста. Манифест хранит паспорт мира — зерно и версию генератора; чек-скрипты сверяют паспорт раньше счётчиков. - **Суточный профиль интенсивности задаёт день-функция.** Форма — волны: ночной провал, обеденный и вечерний пики, различие будней и выходных; пики — до ~2× среднего. С детерминизмом профиль совместим: это часть функции дня, а не внешний шум. Отклонено с доводами: - *Воспроизводимы только состав мира и счётчики*: ломает доигрывание дня через дедуп (другие `WatchID` — дубли вместо склейки) и воспроизводимую отладку. - *События те же, байты не обещаем*: экономия копеечная, а честный diff снимка и тесты «хеш совпал» теряются. - *Общий RNG-поток на все дни*: порядок исполнения менял бы результат — «параллельно равно последовательно» недостижимо. - *Обещание детерминизма поверх обновления зависимостей*: numpy сознательно улучшает алгоритмы распределений между версиями (NEP 19), Faker меняет словари. Фиксация — `uv.lock`; обновление зависимостей — осознанная пересборка манифеста одним PR. ## 3. Контракт схемы Резолюция развилки [«Архитектура»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/29), часть первая. - **Граница вывода — по шву «трекер | хранилище», как data contract.** Контракт схемы — собственность генератора, как формат выгрузки — собственность Метрики. Из контракта выводятся: сам генератор, его валидация и публичное «описание выгрузки» в доках — рендеренная таблица колонок, аналог документации Метрики. - **Форма контракта — импортируемый python-модуль с чистыми данными**: описатели колонок (имя Метрики, тип ClickHouse, тип numpy, нормализованное snake_case-имя, группа полей, порядок), никакой логики. Читаемость для менти несёт рендеренная таблица в доках, не модуль. Уточнение при исполнении (#36): нормализованное имя — имя источника, приведённое к нашему стилю, а не имя атрибута в модели данных. Слой DDS складывает свою модель и называет атрибуты по ней; `dds.event_v` эти имена берёт (раздел 7 мастер-спеки), но контракт их не диктует и тестами не сторожит. - **Сторона хранилища пишется по документации, не генерируется.** DDL `ods.event`, SELECT матвью, `dds.event_v`, трансформации — работа следующих этапов по «описанию выгрузки», как в бою хранилище адаптируется к источнику. Границу сторожит боевой механизм — строгий приём (`Nullable`-разбор со сверкой набора ключей, таблицы `*_errors` — раздел 6 мастер-спеки). Сверка объявлений (`system.columns` поднятого стенда против контракта) здесь стояла вторым механизмом и снята при исполнении #43: сверх строгого приёма она ловила ровно одно — смену типа колонки, — а её ловит и сверка разобранного события, причём на живых данных, а не на объявлениях. Отклонено с доводами: - *Автогенерация DDL хранилища из контракта* (буква раздела 1.4 мастер-спеки до правки): пересекает границу ответственности компонент — в бою хранилище адаптируется к источнику руками, менти пришлось бы объяснять приём, которого в жизни нет. Data contract даёт тот же щит от дрейфа без этой условности. - *YAML как форма контракта*: красота ценой загрузчика и «схемы для схемы»; потребителя вне Python нет — хранилище читает рендеренную документацию, не машинный файл. ## 4. Поток и пакет: сериализатор, проигрыватель, приёмники Резолюция развилки [«Архитектура»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/29), часть вторая. - **Один канонический сериализатор, глупые приёмники.** День-функция выдаёт упорядоченный поток канонических байтов — единственное место, где событие превращается в JSON. Приёмники не знают о содержимом: файл (локальный кэш для пересборки и проверок манифеста), Kafka пачкой — пакетный режим, Kafka с темпом ×60 — живой день. Новых топиков нет. - **Одно событие — одно сообщение Kafka.** «Пачкой» относится к темпу отправки, а не к упаковке: приёмник шлёт события подряд без пауз, но каждое отдельным сообщением. Контракт транспорта, не деталь реализации — сторона хранилища читает топик байтами и кладёт сообщение строкой ([ADR 0005](../adr/0005-event-ingestion.md)), поэтому склейка нескольких событий в одно сообщение сломала бы разбор целиком. - **Даты и время на проводе — ISO-8601.** `EventDate` уезжает как `2026-06-01`, `UTCEventTime` — как `2026-06-01T12:34:56Z`. Довод — читаемость сырья: весь смысл слоя STG в том, что менти открывает колонку `raw` в обычном клиенте и разбирает событие глазами, а число эпохи этот урок убивает. Колонка `ecommerce` — строка, внутри которой лежит экранированный JSON, как отдаёт Метрика. Оговорка про цену разбора, вписанная сюда 6 августа и оказавшаяся неверной: здесь стояло, что `JSONExtract(raw, 'UTCEventTime', 'Nullable(DateTime)')` «принимает ISO без плясок». Не принимает — на строке с суффиксом `Z` он отдаёт NULL, и при исполнении #43 это увело бы в брак все события до единого. Измерено на стенде 7 августа 2026 года; форма на проводе от этого не меняется, меняется выражение разбора на стороне хранилища — `parseDateTimeBestEffortOrNull` вместо `JSONExtract` ([ADR 0005](../adr/0005-event-ingestion.md)). Форму реализует сериализатор (#41), хранилище (#43) читает то, что он положил: порядок тикетов развёрнут 6 августа 2026 года, и отправитель идёт первым. Записана форма всё равно здесь — иначе каждый выберет своё, и разойдётся это уже после приёмки. - **Рабочий выбор сериализатора — orjson**: быстрее stdlib json в 5–14 раз, numpy-массивы и datetime сериализует нативно (заметка исследования #31). Смена библиотеки меняет канонические байты, поэтому проходит как обновление зависимости: осознанная пересборка манифеста одним PR. - **Промежуточные файлы не хранятся.** Файл дня — кэш чистой функции: потерял — пересчитал. В git снимок не попадает (раздел 5). - **Обрыв любого режима — переигровка дня целиком**; дедуп склеивает повторы: `WatchID` детерминированы, повтор — та же строка для ReplacingMergeTree. - **Эталонный снимок при старте стенда — через Kafka, пакетным режимом проигрывателя.** Отдельный механизм заливки не строится: каждый `make up` бесплатно прогоняет весь конвейер на настоящих данных, и строгий приём хранилища проверяет контракт тем же прогоном. Оговорка «если заливка уйдёт в десятки минут — вернуться к прямой загрузке» проверена при фиксации чисел: 14 × 50 тыс. ≈ 700 тыс. событий — расчётно минута-две, запас есть. Отклонено с доводами: - *Отдельный топик / Kafka как хранилище дней*: офсеты и партиции вне обещания детерминизма, retention конечен, в git топик не положишь, хеш с манифестом не сверишь; Kafka на стенде — труба, не хранилище (раздел 7 мастер-спеки). - *Файл как обязательная станция доставки*: доигрывание обрыва уже решено через дедуп, канон держит единственный сериализатор, а не диск; файл остаётся только там, где нужен кэш. - *Прямая загрузка снимка в ClickHouse* (и гибрид с ручным заполнением сырого слоя): второй путь приёма, пустой либо поддельный `stg.hits_raw` — теряются переобработка дня X по `event_date` и урок виртуальных колонок «какая нода читала топик». ## 5. Числа: объёмы, режимы, бюджеты Резолюция развилки [«Производительность»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/30); порядки величин — [исследование #31](../research/2026-08-01-python-batch-generation-speed.md). - **Средний модельный день — ~50 тыс. событий**; суточные волны с пиками до ~2× среднего, будни/выходные; ≈8–12 тыс. сессий, 6–8 тыс. посетителей — правдоподобный средний магазин. - **Эталонный снимок — 14 дней**: две полные календарные недели, D0 — понедельник. Самая короткая длина, при которой есть замороженная зона за окном K = 7, дышащая зона и две волны недельной сезонности. Удлинение до месяца — дешёвый ход (пересборка манифеста), если понадобится. - **В git — только манифест, снимок не хранится.** Снимок генерируется при `make up` и при проверках: артефакт — кэш чистой функции, кэш в git не хранят. Манифест несёт паспорт мира, счётчики и хеши по дням; проверки «пустой git diff» и «пересгенерируй день N — сравни хеш» живут на нём. Каждый `make up` — живая демонстрация детерминизма. Честная потеря — страховка на случай платформенного бага: раньше менти с расходящимися байтами мог взять готовый снимок из git, теперь он упрётся в красный чек манифеста; смягчение — CI гоняет генерацию на amd64 и arm64. - **Живой день — ×60 по умолчанию**: модельные сутки за 24 реальные минуты, суточная волна разворачивается на глазах; темп в среднем ~35 событий/с, в пиковые часы сильных дней — до ~100. Число — значение по умолчанию, переопределяется флагом проигрывателя: ускорение — свойство транспорта, вне обещания воспроизводимости, константой мира не делается. ### Бюджеты и способ замера Схема двухъярусная — урок ADR 0004: пороги впритык к расчёту на разном железе кончаются ритуальным удалением проверки. Ориентиры на референсной машине — в спеке, без автоматики: | Операция | Ориентир | |---|---| | Генерация одного дня | секунды | | Пересборка эталонного мира (14 дней + манифест, без транспорта) | до минуты | | Заливка снимка при `make up` (Kafka → матвью → ODS) | минуты | | Лаг живого дня | секунды | **Автоматический порог один: полный день (50 тыс. событий) генерируется ≤ 30 с.** Расчёт по планке исследования (~2–5×10⁵ событий/с на ядро) — доли секунды; порог держит машинный разброс ×2–5 и ловит деградацию на 1–2 порядка: Faker в горячем цикле, случайная квадратичность. Реализация — pytest-тест с маркером `perf` и таймаутом-обрубанием: обязателен в CI, исключён из быстрой локальной петли, зовётся отдельной целью при правках горячего цикла. Остальное — наблюдаемость без порогов: `make up` и smoke печатают тайминги (генерация и доставка отдельно), проигрыватель логирует лаг. Прототип-замер до этапа 2 не нужен: числа назначены с запасом порядок и больше от планки исследования, планка подтверждена локальной проверкой на машине стенда; первый замер настоящего кода — порог этапа 2. Отклонено с доводами: - *Четыре жёстких CI-ворот на все бюджеты*: машинный разброс против порогов впритык — повторение истории с памятью (ADR 0004); порог оставлен один, грубый, между «×5 шума» и «×100 беды». - *Снимок в git* (статус-кво раздела 8 мастер-спеки): основание из v1 — медленный генератор — съедено детерминизмом и скоростью; остаётся только раздутый репозиторий. *Снимок вложением релиза Gitea*: страховка без раздутия, но лишняя машинерия и вторая правда. - *Снимок 7 дней*: ни одного замороженного дня, сезонность без сравнения. *30 дней*: нового урока не даёт — отложено как дешёвое удлинение. - *×120 / ×1440*: волна смазывается в перемотку либо превращается в пакет с анимацией — живой режим теряет смысл. ## 6. Правила кода этапа 2 Хвосты резолюций, обязательные для реализации: - **Целочисленная случайность** — правило кода, а не пожелание: диапазоны и выбор из таблиц целыми, плавающие распределения системной математики не звать; деньги — в целых копейках. - **Случайные значения — векторно из numpy (PCG64)**; посточный цикл — лишь там, где логика действительно посточная (цепочки сессий). - **Посточные фейкеры (Faker, mimesis) — только для справочников** (каталог и прочие справочные строки), не в горячем цикле: они медленнее numpy на 2–3 порядка. Выбор библиотеки — этапу 2: справочники генерируются один раз, скорость безразлична; возможно, хватит таблиц-литералов в коде и фейкер не понадобится вовсе. - **Сериализация — через единственный канонический сериализатор** (orjson); прямых `json.dumps` по коду нет. - **Распараллеливание — multiprocessing по модельным дням**; внутри дня — однопоточно, единица работы и так крупная. ## 7. Расхождения с мастер-спекой Внесены в мастер-спеку тем же коммитом, что и эта спека: - **Раздел 1.4**: «из контракта выводятся DDL и валидация» заменено на data contract — хранилище пишется по документации, границу сторожит строгий приём (раздел 3 здесь). - **Раздел 8**: артефакт `data/startup_history/` в git заменён манифестом; снимок генерируется на месте (раздел 5 здесь). Туман «политика версионирования артефакта» закрыт этим же ходом: версионируется манифест. - **Раздел 11**: пункт «до этапа 3 зафиксировать требования производительности» закрыт числами раздела 5. - Мелкие согласования там, где текст опирался на артефакт в git: источник переобработки при исчерпании retention Kafka (раздел 7), формулировка этапа 7 (раздел 9). Внесены при исполнении #40 (2026-08-02): - **Раздел 1**: «в параллельных массивах одной длины» уточнено — длина общая внутри группы, а `purchase*` и `product*` между собой разной длины. - **Разделы 4 и 7**: механика класса `amount_delta` названа честнее. Округления `Float64` сами по себе расхождения не дают: деньги считаются целыми копейками, и дробное число в событии одно. Класс создаёт генератор намеренно — так мастер-спека и допускала вторым вариантом. ## 8. Хвосты следующим этапам - **Этап 3 (заказы бэкенда)**: заказу, у которого в клиентском событии стоит промокод, бэкенд обязан дать скидку — иначе данные соврут. Номер заказа обе стороны берут один и тот же (раздел 9). - **Этап 4 (сверка)**: копейки в ценах каталога дают разрыв внутри события — `productPrice` округлён форматом, `purchaseRevenue` точна. В сверку он сам не попадает: клиент шлёт точную сумму, и она сходится с `items_total` бэкенда. Класс `amount_delta` этап 4 всё равно создаёт намеренно — мастер-спека (раздел 4) это и допускает; материал для разговора про Float64 теперь есть. - **Этап 5 (Airflow)**: живой день со стороны хранилища — обычный ETL-даг по расписанию (~раз в 24 минуты); генератор не дорабатывается. - **Этап 5, позиция на оси времени.** Проигрыватель состояния не хранит (раздел 9), поэтому вести позицию — работа того, кто его зовёт. Живёт она переменной Airflow: отсутствие переменной означает мир в зерновом состоянии, заводит и двигает её только даг `next_day` и только по успеху. Довод — генератор отдельная и заменяемая сущность, привязывать его к хранилищу незачем, а `make clean` сносит том метаданных Airflow вместе с данными ClickHouse, так что позиция и мир чистятся одной командой. Расхождение переменной с данными (менти почистил партицию руками) лечится документацией, а не сторожем: постоянная сверка была бы проверкой, красной в норме ([ADR 0002](../adr/0002-monitoring-scope.md)). - **Этап 7 (эталонный мир)**: пересборка — это манифест, не артефакт; CI-генерация на amd64 и arm64. - **Будущие лабы**: перезаливка дня X пакетным режимом проигрывателя — готовая демонстрация идемпотентности конвейера. ## 9. Вопросы нарезки этапа 2 (#34): решения и остатки Туман карты разложен нарезкой по тикетам; решения фиксируются здесь по мере исполнения. Решено при исполнении #38 (2026-08-02): - **Числа притока и состава.** Приток — ~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) — решения владельца до реализации: - **Каталог товаров заводится здесь, а не в #40.** У карточки товара есть `Title` — имя товара, а имена живут в каталоге: карточка без каталога невозможна, и без карточек #39 сделал бы примерно половину трафика. Файл `data/catalog/products.csv` (`sku`, `name`, `category`, `brand`, `price`) — общий у генератора и словаря ClickHouse; #40 получает готовый. Решена форма, а не длина: артикул — четыре буквы категории и четыре цифры, цена — целые копейки, категорий шесть. Строки дописываются механически, поэтому ни код, ни тесты их не считают, а товар для карточки выбирается равномерно внутри категории. - **Ассортимент — непродовольственная розница** (товары для дома, текстиль, посуда, мелкая бытовая техника, детское, одежда и обувь), ~150–200 sku. Довод не вкусовой: числа мира приняты под этот профиль — цикл повторной покупки в месяцы, 75% одноразовых кук, конверсия ~2% на визит. Продуктовая сеть требовала бы других чисел, то есть переоткрытия #38. - **География — один регион присутствия**: Поволжье с центром в Самаре, города своего региона и тонкий хвост остальной страны. «Топ городов России» дал бы магазину с одним складом карту, которой у него не бывает. - **Модельные сутки считаются в часовом поясе счётчика** (UTC+4), как в выгрузке Метрики: `EventDate` — дата в поясе счётчика, `UTCEventTime` — абсолютная метка. Шов суток приходится на местную полночь и не режет утренние сессии, а регион мира выбирается свободно. Суточная волна задана в местном времени посетителя: гостю из другого пояса профиль поворачивается на разницу, и пик слегка размазывается — как в жизни. Следствие для соседних этапов: `toDate(UTCEventTime)` ≠ `EventDate`; оно записано в описание выгрузки, иначе сторона хранилища выведет дату сама и разойдётся на несколько часов данных. - **Паспорт куки — постоянная часть мира, а не поведение дня.** Браузер, ОС, устройство, экран и город у куки одни во всех её днях: кука — это браузер на устройстве. Держит их план состава (`Cohort` и `DayAudience` прирастают двумя массивами), разворачивает в поля события день-функция. Выводить паспорт арифметикой из `ClientID` дешевле, но про двухкуковые пары знает только план, а два города у одного человека — ложь в данных; пара получает один город и разные устройства («телефон и ноутбук», мастер-спека, раздел 5). Броски паспорта приписаны последними, поэтому измеренные числа канонического мира не сдвинулись: прогон счётчиков до и после дал те же 3 828 / 6 235–7 124 / 68 119 / 170. - **Справочники — таблицы-литералы** рядом с конфигурацией мира; Faker и mimesis не подключаются. Нужны не случайные строки, а связки (город → id региона → часовой пояс; телефон → Safari → iOS → размер экрана) — их фейкер не даёт, таблицу пришлось бы написать всё равно, а новая зависимость молча меняла бы мир при обновлении своих словарей. - **Гео-id — настоящие числа геобазы Яндекса.** Проверка 2026-08-02: id подтверждались обращением к живым сервисам Яндекса по тому же номеру (`yandex.ru/pogoda/`, `yandex.ru/maps/225/russia/`) — страница открывает ожидаемое место. Оговорка стоит в коде: опубликованной таблицы геобазы найти не удалось, а что `RegionCityID` Метрики нумерует регионы той же геобазой — обоснованное допущение, не подтверждённый источником факт. Выдуманные по памяти числа не годятся ни в каком случае. - **IP-адреса — нероутируемые диапазоны**: документационные сети RFC 5737 и benchmark-сеть 198.18/15 по ломтю на город, телефонам — CGNAT 100.64/10, как у настоящих операторов. Правдоподобные публичные адреса принадлежат живым организациям, и в учебных данных им не место. - **Словарь: «визит» остаётся за сессией** и полем `VisitID`. Одноимённое понятие плана состава переименовано в «день активности»: у куки он один на день, а визитов внутри дня бывает несколько. Решено при исполнении #39 самой реализацией: - **Правила резки визитов** (мастер-спека, раздел 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); позже обошлось бы его пересборкой. Часть решений принята после холодного ревью первой редакции блока. - **Корзина шире заказа.** Посетитель кладёт в корзину товары тех карточек, которые открывал в этом визите, а покупает не всё: около 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 самой реализацией: - **Торговый хвост разводит и соседние визиты, а не только сутки.** Правило резки требует, чтобы визиты одной куки стояли дальше таймаута; считать это расстояние от последней страницы, когда последним событием визита бывает покупка, значило отдать таймауту торговый хвост — лаба сессий склеила бы два визита в один и разошлась бы с `VisitID`. Расстояние считается от последнего события; поймано тестом сборки визитов. - **Полночь забирает подтверждение вместе с его покупкой.** Резерв суток сначала считался только для визитов с обещанным планом заказом, и у вольной покупки оставалось окно в несколько секунд: страница подтверждения дожила до потока, а событие покупки за полночь вышло. Это была бы потеря на клиентской стороне — класс этапов 4 и 6, заведённый по недосмотру. Теперь запас под торговый хвост есть у всякой страницы подтверждения: либо она уходит за границу суток вместе с покупкой, либо остаются обе. Это ровно то правило, которое блок выше называет словами «визит, у которого страница подтверждения срезана полуночью, покупки не даёт»; в каноническом мире числа от правки не сдвинулись — случай не выпал ни разу за 14 дней. - **Задержка торгового события короче самой короткой паузы между страницами.** Тогда событие корзины не обгоняет страницу, на которой посетитель нажал кнопку, и не выходит за полночь: за ним в том же визите всегда идёт страница, которая полночь пережила. - **Номера торговых событий — из торгового подпотока.** `WatchID` строки корзины и покупки рисует `COMMERCE`: возьми их у трафика, и правка торгового поведения сдвинула бы трафиковые номера. Ряды двух подпотоков проверяются на непересечение — обещание уникальности держит дедупликацию при переигровке дня, и предполагать его нельзя. - **Вариант товара — справочник мира.** Сырой `ecommerce` несёт `variant`, которого в плоских колонках нет; строки живут в справочниках рядом с устройствами и городами, а не сочиняются на месте. Решено при исполнении #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%. Заказ рисуется из корзины, а корзина сузилась, поэтому доля брошенных позиций и состав заказа перемеряются вместе с остальным: сохраняется не число, а свойство — заказ уже корзины и однопозиционных заказов около половины. **Измерено после правки #50** (канонический мир, 14 дней; правый столбец — то же до правки). Доли по уровням и состав заказа считаются по окну снимка целиком, а не как среднее дневных долей: заказов в дне около 240, и на одном дне такая доля гуляет на несколько пунктов от одной случайности выборки. Трафиковая половина не сдвинулась ни на строку: набор просмотров страниц сверен со снимком, снятым до первой правки. | величина | стало | было | |---|---|---| | дневная аудитория | 6 637–7 595, средняя 7 166 | без изменений | | визитов в день | 9 879 | без изменений | | событий в день, средних | 49 834 | 49 509 | | просмотров карточек в день | 25 067 | без изменений | | событий корзины в день | 2 161, в 1 306 визитах | 1 836, в 765 визитах | | из них визиты без страницы `/cart` | 541 — 41,4% | 0 | | визитов со страницей `/cart` без позиции | 0 | 0 | | просмотров карточек, дошедших до корзины | 8,62% | 7,32% | | конверсия карточки в корзину: магнит / обычный / залёживается | 13,1% / 9,2% / 5,8% | 7,7% у всех | | событий корзины вне `/cart`: магнит / обычный / залёживается | 42,0% / 20,2% / 17,1% | 0 у всех | | конверсия визита в покупку | 2,43% | без изменений | | позиций на заказ | 1,74; однопозиционных 56,4% | 1,87; 50,3% | | брошено позиций в покупающих корзинах | 7,25% | 12,0% | Знаменатель «просмотров карточек» здесь — только просмотры страниц. При постановке #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) и порог скорости дня. Упрётся будущая правка — двигать надо бюджет и его причины, а не долю. Решено при исполнении #41 (2026-08-07). Первые два пункта — решения владельца грилингом 7 августа 2026 года, внесённые как есть; остальные приняты при исполнении. - **Генератор живёт в своём контейнере**, и на этапе 5 даг будет запускать контейнер, а не импортировать пакет в процессе воркера. Довод учебный: так граница между оркестратором и задачей видна глазами — у задачи своё окружение, свой образ, свой процесс, а оркестратор передаёт параметры и читает результат. Это та же ступенька, с которой в бою вырастает `KubernetesPodOperator`. Побочно это единственный вариант, при котором «генератор — отдельная и заменяемая сущность» перестаёт быть словами: его зависимости не смешиваются с окружением Airflow. Хостовый `uv` остаётся для `make test`, `make lint` и `make typecheck`. Цена названа и принимается: чтобы даг запускал контейнер, в 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). Зерно (каноническое), число дней (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`. Тогда каталог в образе есть, а модуль его не находит. Проверено запуском в контейнере; заодно день, сыгранный в образе, совпал побайтово с днём, сыгранным на машине. Остаётся открытым, за тикетами: - как фиксируется «зерновой» мир конца этапа 2 (раздел 9 мастер-спеки): с манифестным решением напрашивается мини-манифест зернового мира — форма за #42; - паспорт мира в манифесте (зерно и версия генератора) файл каталога не накрывает: правка цены в CSV меняет мир молча. Манифесту нужен хеш каталога — хвост для #42.