Files
clickstream-data-platform/docs/specs/2026-08-01-generator.md
T
ddadminandClaude Opus 5 7df5e482b9 docs(specs): правки по холодному ревью — долг словаря и потерянные доводы
- Зачем:
  - холодное ревью (Fable, свежая сессия) нашло невыполненный хвост
    тикета #32 и места, где доводы резолюций сжались до непонятности.
- Что:
  - мастер-спека 1.1: «склад» заменён на «хранилище» (хвост #32);
    CONTEXT.md: DWH в избегаемых, отдельная статья «Пакетный режим».
  - спека: восстановлены доводы «на маке и в WSL тоже» и «менти упрётся
    в красный чек манифеста»; обещания про diff привязаны к манифесту;
    темп ×60 и расчёт порога согласованы с числами разделов; заголовок
    притока честен про затухание; выход за мандат оговорён в «Зачем».
  - раздел 9: добавлены числа притока и календарная дата-константа D0;
    заметка исследования: у Faker единицы «значений/с», не «строк/с».
- Проверка:
  - вычитка; решения развилок не пересматриваются, правки текстовые.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 21:00:18 +03:00

348 lines
32 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): функциональный мир, детерминизм до байта, контракт схемы, числа скорости
Статус: 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, 911; заметка
[«Скорость батчевой генерации в 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 в 514 раз,
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 мастер-спеки):
с манифестным решением напрашивается мини-манифест зернового мира — форму
выбрать при нарезке.