Files
clickstream-data-platform/docs/specs/2026-08-01-generator.md
T
ddadminandClaude Fable 5 6f18e9989c 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>
2026-08-28 08:16:11 -04:00

952 lines
97 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Генератор (этап 2): функциональный мир, детерминизм до байта, контракт схемы, числа скорости
Статус: 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).
Источники: мастер-спека
[«Боевой реализм стенда (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 — расширение подтверждено владельцем в резолюции
«Производительности».
## Чем меряется генератор
Рамка владельца, по которой принимались решения ниже. Она шире этапа 2 и
живёт дальше него: спеки следующих этапов ссылаются сюда, а не переписывают.
- **Генератор меряется тем, что приезжает на выход, а не устройством
внутри.** Он играет два источника с разными темпами: поток Метрики и
ночную выгрузку бэкенда магазина. Любое внутреннее решение отвечает перед
одним вопросом — правдоподобны ли данные на выходе.
- **Менти внутрь не смотрит.** Он видит внешние эффекты и по ним составляет
представление, как работает магазин и обо что там спотыкаются. Значит,
конструкция внутри оправдана только наблюдаемым эффектом: без него это
сложность, за которую никто не заплатил.
- **Повторимы должны быть эффекты, а не конкретные числа.** Правка свойств
мира сдвигает почти все числа разом — генератор случайных чисел выдаёт
другую последовательность. Опираться на конкретные числа поэтому нечем.
Хеши описи обещают не постоянство чисел, а то, что их сдвиг не пройдёт
молча: пересобрал опись — увидел diff.
- **Цена ошибки здесь мала.** В худшем случае испорчен урок, и чинится он
правкой генератора после взгляда на получившиеся распределения. Оборона,
которая дороже ошибки, не заводится.
- **Сложность изолирована нарочно.** Генератор вынесен отдельной службой,
чтобы его сложность не расползалась по стенду. Плата за изоляцию —
дешевизна правки: выбирается устройство, которое дёшево менять, а не то,
которое всё предусмотрело.
## Целевая картина одним взглядом
- **Мир — функция, не состояние.** Состав мира — чистая функция зерна;
модельный день D — функция (зерно, D). Между прогонами живут только зерно
и позиция на оси времени.
- **Детерминизм до байта.** Одно зерно — побайтово тот же снимок; сверка —
хешами описи. Транспорт (офсеты Kafka, темп) — вне обещания.
- **Схема — контракт генератора.** Python-модуль с чистыми данными;
хранилище строится по рендеренной документации, границу сторожит строгий
приём на стороне хранилища.
- **Один сериализатор, глупые приёмники.** День-функция выдаёт канонические
байты; приёмники — файл, Kafka пачкой, Kafka с темпом.
- **Числа.** Средний день ~50 тыс. событий; эталонный снимок — 14 дней;
в git — только опись мира; автоматических порогов по времени нет.
## 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). Между прогонами живут только зерно и
позиция на оси: выключенный ноутбук — мир замер, потом продолжил.
- **Два режима движения по одной оси.** Пошаговый — базовый для лаб: старт
с эталонного снимка, дальше «прожить следующий день» — явное действие.
Живой день — текущий день проигрывается с ускорением, дашборд и мониторинг
«дышат»; включается по требованию — постоянным фоном идёт, только пока
включён выключатель пульта ([ADR 0009](../adr/0009-world-control.md)).
- **Граница суток — единственный структурный шов.** Сессии режутся по ней,
дневная партиция самодостаточна; в конце модельного дня — слепок заказов.
День проживается целиком, полдня не бывает: недожитый из-за обрыва день
переигрывается (раздел 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);
(зерно, день) → подпоток дня → именованные подпотоки компонентов: трафик,
торговые события, заказная и событийная стороны (судьба заказов и порча
событийного потока) — в фиксированном порядке (уточнение [судьбой
заказа](../architecture/orders/fate.md): прежние «расхождения» и
«опоздания» были названы по классам витрины, а компонент называет часть
мира).
По построению: параллельный прогон равен последовательному; продление
истории днём 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 года; форма на проводе от этого
не меняется, меняется выражение разбора на стороне хранилища — `parseDateTime`
по буквально названному формату вместо `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, пакетным режимом
проигрывателя.** Отдельный механизм заливки не строится: `world_initialize`
прогоняет весь конвейер на настоящих данных при первом `make up` и при явном
`make rebuild-storage`. На готовом мире повторный `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 — только опись, снимок не хранится.** Снимок генерируется при
создании мира и в проверках генератора: артефакт — кэш чистой функции, кэш в
git не хранят. Опись несёт паспорт мира, счётчики и хеши по дням; проверки
«пустой git diff» и «пересгенерируй день N — сравни хеш» живут на ней.
Первый `make up` пустого стенда — живая демонстрация детерминизма; готовый мир
повторный подъём не пересчитывает. Честная потеря —
страховка на случай платформенного бага: раньше менти с расходящимися
байтами мог взять готовый снимок из git, теперь он упрётся в красный чек
описи; смягчение — CI гоняет генерацию на amd64 и arm64.
- **Живой день — ×60 по умолчанию**: модельные сутки за 24 реальные минуты,
суточная волна разворачивается на глазах; темп в среднем ~35 событий/с,
в пиковые часы сильных дней — до ~100. Число —
значение по умолчанию, переопределяется флагом проигрывателя: ускорение —
свойство транспорта, вне обещания воспроизводимости, константой мира не
делается.
### Бюджеты и способ замера
Схема двухъярусная — урок ADR 0004: пороги впритык к расчёту на разном
железе кончаются ритуальным удалением проверки.
Ориентиры на референсной машине — в спеке, без автоматики. Где стоит замер,
там он с датой; остальное — порядок величины, пока не мерили:
| Операция | Ориентир |
|---|---|
| Генерация одного дня | 1,7–2,8 с (замер 7 августа 2026 года) |
| Пересборка эталонного мира (14 дней + опись, без транспорта) | до минуты |
| Заливка стартового мира при `make up` (8 дней, Kafka → матвью → ODS) | 22,5 с (замер 7 августа 2026 года) |
| Лаг живого дня | секунды |
**Автоматических порогов нет ни одного** — решение владельца 7 августа
2026 года при исполнении #42. Здесь стоял единственный: «полный день
генерируется ≤ 30 с». Назначен он был до того, как генератор написали, а
первый замер настоящего кода дал **1,7 секунды на день в 50 626 событий**
(машина стенда, 7 августа 2026 года). Порог оказался в восемнадцать раз выше
факта: зелен при любой правдоподобной регрессии, то есть не сторож, а
украшение. Как здесь говорят о времени, к тому дню уже решила [карта
целей](../architecture/testing.md): цена — замеренное число с датой, а не
назначенный предел.
Наблюдаемость без порогов остаётся и делает всю работу: проигрыватель
печатает тайминги генерации и доставки раздельно, лаг живого дня — в его
логе, а `world_initialize` гоняет генератор по восемь дней при первом создании
мира и явной пересборке. Замедлись он на порядок — операция встанет колом, и
это увидит первый же человек, который создаёт мир; повторный `make up` готовый
мир не пересчитывает.
Отклонено с доводами:
- *Четыре жёстких CI-ворот на все бюджеты*: машинный разброс против порогов
впритык — повторение истории с памятью (ADR 0004). Оставленный было один
грубый порог снят при исполнении #42 (выше).
- *Снимок в 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 —
[ADR 0009](../adr/0009-world-control.md))**.** Проигрыватель состояния не
хранит (раздел 9), поэтому вести позицию — работа того, кто его зовёт. Живёт
она переменной Airflow: отсутствие переменной означает, что стартовый мир
ещё не создан. В обычной жизни её ставит даг пульта, который сыграл день, —
значением «последний сыгранный + 1» и только по успеху. Даги жизненного цикла
используют `-1` для незавершённой очистки, `0` для чистого мира до генерации
и ставят первый несыгранный день только после приёма стартового мира
([ADR 0013](../adr/0013-world-lifecycle.md)).
Довод — генератор отдельная и заменяемая сущность, привязывать его к
хранилищу незачем. Полный `make clean` сносит метаданные Airflow вместе с
данными ClickHouse, а узкая пересборка явно сводит сохранённую позицию с
заново залитым миром.
Расхождение переменной с данными (менти почистил партицию руками) лечится
документацией, а не сторожем: постоянная сверка была бы проверкой, красной
в норме ([ADR 0002](../adr/0002-monitoring-scope.md)).
- **Этап 7 (эталонный мир)**: пересборка — это опись, не артефакт;
CI-генерация на amd64 и arm64.
- **Будущие лабы**: перезаливка дня X пакетным режимом проигрывателя —
готовая демонстрация идемпотентности конвейера.
## 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 и в спеке этапа, на котором их принимают.
Журнал собран по темам, но дата и тикет остались у каждого решения. По
журналу видно, какие границы затрагивала каждая правка: иногда она меняла
сразу план состава, воронку и каталог. При #40 это было дешево — опись еще
не собрали; позже такой ход потребовал бы пересборки описи.
### План состава и числа мира
- **Числа притока и состава** (#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 обещает до генерации только состав — посетителей, приток и
пары. Торговые счетчики складываются из поведения дня. Квота заставила бы день
добирать точное число и сделала бы конверсию подозрительно ровной: условность
была бы видна именно там, куда смотрит лаба. Сходятся пары; покупки
проверяются после генерации по дню и вилке конверсии.
### Время, визиты и границы дня
- **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). Событие корзины не обгоняет страницу кнопки и не выходит за
полночь: за ним в визите всегда есть страница, пережившая полночь.
### Каталог, география и паспорт куки
- **Каталог товаров заводится в #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`, которого нет в плоских колонках; строки живут рядом со
справочниками устройств и городов, а не сочиняются на месте.
### Корзина и состав заказа
После приемки #40 (2026-08-02) на грилинге «просмотр → корзина» нашли два
точных равенства. В живых данных таких равенств нет: аналитик прочитает
их как склейку разметки вместо поведения людей.
- **Корзина шире заказа** (#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). Раньше
внутри воронки клали все карточки, вне ее — ни одной: конверсия по 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. Если будущая правка упрется в него, двигать надо бюджет и его
причины, а не долю.
### Номер заказа, промокод и деньги
- **Номер заказа — день и порядковый номер покупки** (#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 заказов в дне дают слишком шумную долю.
Трафиковая половина не сдвинулась ни на строку.
| величина | стало | было |
|---|---|---|
| дневная аудитория | 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%. Величина
одна и та же, счет разный.
### Запуск, упаковка и приемники
Первые два решения владелец принял на грилинге 7 августа 2026 года при #41;
остальные приняты при исполнении #41 (2026-08-07).
- **Генератор живет в своем контейнере** (#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). Каноническое зерно, `--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; решение владельца
при нарезке, 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). Обычные русские слова убирают пару
«манифест / мини-манифест» и необходимость различать две сущности там, где
вещь одна.
### Открытое на момент заморозки
- **`ParsedParamsKey1` остается пустой** (#39, 2026-08-02) — отложенное решение,
а не дыра. Вариант A/B-теста был бы постоянным свойством куки, а не поведением
дня; его заводит тикет #47.