# Генератор (этап 2): функциональный мир, детерминизм до байта, контракт схемы, числа скорости Статус: Proposed — ждёт приёмки владельцем (тикет #33). Дата: 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-модуль с чистыми данными; хранилище строится по рендеренной документации, границу сторожит contract-тест. - **Один сериализатор, глупые приёмники.** День-функция выдаёт канонические байты; приёмники — файл, Kafka пачкой, Kafka с темпом. - **Числа.** Средний день ~50 тыс. событий; эталонный снимок — 14 дней; в git — только манифест; автоматический порог один — день ≤ 30 с. ## 1. Модель мира Резолюция развилки [«Модель мира»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/27). - **Мир функциональный, ничего не мутирует.** Постоянный состав мира — популяция посетителей, их привычки, календарь двухкуковых пар — чистая функция зерна, вычисляется при старте любого процесса. День D — функция (зерно, D); межднёвные связи (окно заказов K, опоздания) выводятся из плана состава, а не копятся в состоянии. Глобальные инварианты («каждый двухкуковый покупатель заказал с обеих кук») гарантируются планом — счётчики манифеста известны до генерации событий. - **Состав не замкнут: посетители появляются и затухают.** План состава задаёт календарь появления — у каждого посетителя есть дата первого визита и профиль возвратов, включая затухание: заметная доля кук одноразовая, как в живом трафике. Новые посетители появляются на всём протяжении оси: uniq(ClientID) растёт с горизонтом, дневная и накопленная аудитории не сходятся в одно число. Приток — часть плана, а не мутация: счётчики манифеста по-прежнему известны до генерации (уточнение по вычитке владельца, 2026-08-01); его числа — раздел 9. - **Своя ось модельного времени.** Мир рождается в фиксированный день D0 (понедельник — см. раздел 5); реальный календарь в модели не участвует. В `EventDate`/`UTCEventTime` дни оси ложатся конкретными датами, но это константа мира, от даты запуска не зависящая (значение — раздел 9). Между прогонами живут только зерно и позиция на оси: выключенный ноутбук — мир замер, потом продолжил. - **Два режима движения по одной оси.** Пошаговый — базовый для лаб: старт с эталонного снимка, дальше «прожить следующий день» — явное действие. Живой день — текущий день проигрывается с ускорением, дашборд и мониторинг «дышат»; включается по требованию, не постоянный фон. - **Граница суток — единственный структурный шов.** Сессии режутся по ней, дневная партиция самодостаточна; в конце модельного дня — слепок заказов. День проживается целиком, полдня не бывает: недожитый из-за обрыва день переигрывается (раздел 4). - **Поток и пакет совместимы по построению.** День-функция выдаёт один упорядоченный поток событий; режимы отличаются только способом проигрывания — пачкой или с темпом. Отклонено с доводами: - *Мутирующее состояние мира* («мир стареет»): ломает параллельность по дням, требует чекпоинтов, счётчики манифеста узнаваемы только постфактум; ни один урок стенда на старении не стоит. - *Чистая функция без слоя состава*: глобальные инварианты пришлось бы выводить в каждом дне заново — тот же план мира, но неявный и размазанный. - *Привязка модельного времени к реальному календарю* (T-1 с догоном): конфликтует с ускорением ×60 — за вечер мир уезжает в будущее — и делает даты эталонного мира зависимыми от даты запуска, манифест теряет воспроизводимость. ## 2. Детерминизм от зерна Резолюция развилки [«Детерминизм от зерна»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/28). - **Обещание — содержимое до байта.** Два прогона с одним зерном дают тот же набор событий: те же `WatchID`/`VisitID`, поля, метки модельного времени. Снимок при пересборке побайтово совпадает: канонический порядок ключей и строк; сверка — по хешам манифеста, а два локально пересобранных снимка сравнимы обычным diff — пустой означает «ничего не изменилось». Вне обещания — транспорт: офсеты и партиции Kafka, какая нода прочитала, `_ingested_at`, темп живого дня. - **Условия обещания.** Детерминизм держится при зафиксированном `uv.lock` и внутри канонического контейнера — то есть везде Linux, на маке и в WSL тоже; единственная переменная — архитектура CPU. Истина — CI на Linux; сходимость любой машины проверяет скрипт «пересгенерируй день N — сравни хеш с манифестом». Расхождение на любой платформе — баг генератора, а не допуск. - **Раздача зерна — иерархией подпотоков.** Корневое зерно → состав мира; (зерно, день) → подпоток дня → именованные подпотоки компонентов: трафик, торговые события, расхождения, опоздания — в фиксированном порядке. По построению: параллельный прогон равен последовательному; продление истории днём 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-имя для DDS, группа полей, порядок), никакой логики. Читаемость для менти несёт рендеренная таблица в доках, не модуль. - **Сторона хранилища пишется по документации, не генерируется.** DDL `ods.event`, SELECT матвью, `dds.v_event`, трансформации — работа следующих этапов по «описанию выгрузки», как в бою хранилище адаптируется к источнику. Границу сторожат два боевых механизма: строгий приём (`input_format_skip_unknown_fields = 0`, таблицы `*_errors` — раздел 6 мастер-спеки) и contract-тест в smoke — сравнение `system.columns` поднятого стенда со схемой генератора. Отклонено с доводами: - *Автогенерация DDL хранилища из контракта* (буква раздела 1.4 мастер-спеки до правки): пересекает границу ответственности компонент — в бою хранилище адаптируется к источнику руками, менти пришлось бы объяснять приём, которого в жизни нет. Data contract даёт тот же щит от дрейфа без этой условности. - *YAML как форма контракта*: красота ценой загрузчика и «схемы для схемы»; потребителя вне Python нет — хранилище читает рендеренную документацию, не машинный файл. ## 4. Поток и пакет: сериализатор, проигрыватель, приёмники Резолюция развилки [«Архитектура»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/29), часть вторая. - **Один канонический сериализатор, глупые приёмники.** День-функция выдаёт упорядоченный поток канонических байтов — единственное место, где событие превращается в JSON. Приёмники не знают о содержимом: файл (локальный кэш для пересборки и проверок манифеста), Kafka пачкой — пакетный режим, Kafka с темпом ×60 — живой день. Новых топиков нет. - **Рабочий выбор сериализатора — orjson**: быстрее stdlib json в 5–14 раз, numpy-массивы и datetime сериализует нативно (заметка исследования #31). Смена библиотеки меняет канонические байты, поэтому проходит как обновление зависимости: осознанная пересборка манифеста одним PR. - **Промежуточные файлы не хранятся.** Файл дня — кэш чистой функции: потерял — пересчитал. В git снимок не попадает (раздел 5). - **Обрыв любого режима — переигровка дня целиком**; дедуп склеивает повторы: `WatchID` детерминированы, повтор — та же строка для ReplacingMergeTree. - **Эталонный снимок при старте стенда — через Kafka, пакетным режимом проигрывателя.** Отдельный механизм заливки не строится: каждый `make up` бесплатно прогоняет весь конвейер и contract-тест на настоящих данных. Оговорка «если заливка уйдёт в десятки минут — вернуться к прямой загрузке» проверена при фиксации чисел: 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 — хранилище пишется по документации, границу сторожит contract-тест (раздел 3 здесь). - **Раздел 8**: артефакт `data/startup_history/` в git заменён манифестом; снимок генерируется на месте (раздел 5 здесь). Туман «политика версионирования артефакта» закрыт этим же ходом: версионируется манифест. - **Раздел 11**: пункт «до этапа 3 зафиксировать требования производительности» закрыт числами раздела 5. - Мелкие согласования там, где текст опирался на артефакт в git: источник переобработки при исчерпании retention Kafka (раздел 7), формулировка этапа 7 (раздел 9). ## 8. Хвосты следующим этапам - **Этап 5 (Airflow)**: живой день со стороны хранилища — обычный ETL-даг по расписанию (~раз в 24 минуты); генератор не дорабатывается. - **Этап 7 (эталонный мир)**: пересборка — это манифест, не артефакт; CI-генерация на amd64 и arm64. - **Будущие лабы**: перезаливка дня X пакетным режимом проигрывателя — готовая демонстрация идемпотентности конвейера. ## 9. Решается при нарезке этапа 2 (#34) Осталось из тумана карты — вопросы уровня тикетов, не развилок: - интерфейс запуска генератора (CLI / цели make) и как он делит режимы проигрывателя; кто его зовёт в стенде — даги `world_init`/`next_day` из оценки мастер-спеки (раздел 9) — и в каком контейнере он живёт; - числа притока посетителей: доля одноразовых кук и темп появления новых (раздел 1); - календарная дата-константа D0: каким числом дни оси ложатся в `EventDate`/`UTCEventTime`; - формат описания мира и конфигурации (что константа кода, что параметр); - как фиксируется «зерновой» мир конца этапа 2 (раздел 9 мастер-спеки): с манифестным решением напрашивается мини-манифест зернового мира — форму выбрать при нарезке.