Files
clickstream-data-platform/docs/specs/2026-08-01-generator.md
T
ddadminandClaude Opus 5 24c8dd9b98 refactor(generator): нормализованное имя вместо имени в DDS, тесты на имена сняты
- Зачем:
  - контракт вёл себя как хозяин чужого слоя: поле называлось dds_name, в
    описании стоял столбец «Имя в DDS», а два теста прибивали имена
    гвоздями. Спека же задала вид имени (snake_case), а не список: имена
    атрибутов складывает модель данных DDS, и решать это не трекеру.
- Что:
  - поле контракта и столбец описания стали нормализованным именем: имя
    источника в нашем стиле. В описании и в докстринге сказано прямо, что
    слой DDS называет атрибуты по своей модели.
  - сняты оба теста на имена — копия имён DDS и конспект состава по
    мастер-спеке. Они не проверяли верность имени, только неизменность, а
    неизменность и так сторожит пересборка описания: молчаливой правки
    контракта не бывает, она всплывает диффом документа.
  - остались проверки формы: 47 колонок, уникальность, стили имён,
    заполненность, согласие типов numpy и ClickHouse, порядок групп.
  - спека генератора (раздел 3) и CONTEXT.md согласованы тем же
    коммитом: уточнение внесено как расхождение, найденное при исполнении.
- Проверка:
  - make test (248 тестов), make lint, make config-test;
  - make docs, затем git diff --exit-code docs/ — пусто.

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

32 KiB
Raw Blame History

Генератор (этап 2): функциональный мир, детерминизм до байта, контракт схемы, числа скорости

Статус: Proposed — ждёт приёмки владельцем (тикет #33). Дата: 2026-08-01. Мандат — тикет #14 (этап 2, родитель #4), карта #26. Развилки: модель мира (#27), детерминизм от зерна (#28), архитектура вывода (#29), производительность (#30); исследование скорости (#31). Источники: мастер-спека «Боевой реализм стенда (v2)» — разделы 1.4, 5, 9–11; заметка «Скорость батчевой генерации в Python».

Зачем

Мастер-спека решила, что генерирует стенд: широкое событие в 47 колонок, таксономию, анонимность, двухкуковых покупателей, заказы слепками. Четыре развилки о том, как генератор устроен, она отложила: модель мира, границы детерминизма, источник истины схемы с разделением потока и пакета, числовые требования скорости. Карта #26 эти развилки прошла; спека собирает решения в одну картину. По ней этап 2 режется на тикеты (#34).

Здесь не переоткрывается решённое мастер-спекой: модель данных события, таксономия, анонимность, механика заказов (границы мандата #14). Генератор слепков заказов — этап 3: эта спека лишь не должна ему мешать. Один осознанный выход за границы: решение о хранении снимка (раздел 5) формально касается этапа 7 — расширение подтверждено владельцем в резолюции «Производительности».

Целевая картина одним взглядом

  • Мир — функция, не состояние. Состав мира — чистая функция зерна; модельный день D — функция (зерно, D). Между прогонами живут только зерно и позиция на оси времени.
  • Детерминизм до байта. Одно зерно — побайтово тот же снимок; сверка — хешами манифеста. Транспорт (офсеты Kafka, темп) — вне обещания.
  • Схема — контракт генератора. Python-модуль с чистыми данными; хранилище строится по рендеренной документации, границу сторожит contract-тест.
  • Один сериализатор, глупые приёмники. День-функция выдаёт канонические байты; приёмники — файл, Kafka пачкой, Kafka с темпом.
  • Числа. Средний день ~50 тыс. событий; эталонный снимок — 14 дней; в git — только манифест; автоматический порог один — день ≤ 30 с.

1. Модель мира

Резолюция развилки «Модель мира».

  • Мир функциональный, ничего не мутирует. Постоянный состав мира — популяция посетителей, их привычки, календарь двухкуковых пар — чистая функция зерна, вычисляется при старте любого процесса. День D — функция (зерно, D); межднёвные связи (окно заказов K, опоздания) выводятся из плана состава, а не копятся в состоянии. Глобальные инварианты («каждый двухкуковый покупатель заказал с обеих кук») гарантируются планом — счётчики манифеста известны до генерации событий.
  • Состав не замкнут: посетители появляются и затухают. План состава задаёт календарь появления — у каждого посетителя есть дата первого визита и профиль возвратов, включая затухание: заметная доля кук одноразовая, как в живом трафике. Новые посетители появляются на всём протяжении оси: uniq(ClientID) растёт с горизонтом, дневная и накопленная аудитории не сходятся в одно число. Приток — часть плана, а не мутация: счётчики манифеста по-прежнему известны до генерации (уточнение по вычитке владельца, 2026-08-01); его числа — раздел 9.
  • Своя ось модельного времени. Мир рождается в фиксированный день D0 (понедельник — см. раздел 5); реальный календарь в модели не участвует. В EventDate/UTCEventTime дни оси ложатся конкретными датами, но это константа мира, от даты запуска не зависящая (значение — раздел 9). Между прогонами живут только зерно и позиция на оси: выключенный ноутбук — мир замер, потом продолжил.
  • Два режима движения по одной оси. Пошаговый — базовый для лаб: старт с эталонного снимка, дальше «прожить следующий день» — явное действие. Живой день — текущий день проигрывается с ускорением, дашборд и мониторинг «дышат»; включается по требованию, не постоянный фон.
  • Граница суток — единственный структурный шов. Сессии режутся по ней, дневная партиция самодостаточна; в конце модельного дня — слепок заказов. День проживается целиком, полдня не бывает: недожитый из-за обрыва день переигрывается (раздел 4).
  • Поток и пакет совместимы по построению. День-функция выдаёт один упорядоченный поток событий; режимы отличаются только способом проигрывания — пачкой или с темпом.

Отклонено с доводами:

  • Мутирующее состояние мира («мир стареет»): ломает параллельность по дням, требует чекпоинтов, счётчики манифеста узнаваемы только постфактум; ни один урок стенда на старении не стоит.
  • Чистая функция без слоя состава: глобальные инварианты пришлось бы выводить в каждом дне заново — тот же план мира, но неявный и размазанный.
  • Привязка модельного времени к реальному календарю (T-1 с догоном): конфликтует с ускорением ×60 — за вечер мир уезжает в будущее — и делает даты эталонного мира зависимыми от даты запуска, манифест теряет воспроизводимость.

2. Детерминизм от зерна

Резолюция развилки «Детерминизм от зерна».

  • Обещание — содержимое до байта. Два прогона с одним зерном дают тот же набор событий: те же 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. Контракт схемы

Резолюция развилки «Архитектура», часть первая.

  • Граница вывода — по шву «трекер | хранилище», как data contract. Контракт схемы — собственность генератора, как формат выгрузки — собственность Метрики. Из контракта выводятся: сам генератор, его валидация и публичное «описание выгрузки» в доках — рендеренная таблица колонок, аналог документации Метрики.
  • Форма контракта — импортируемый python-модуль с чистыми данными: описатели колонок (имя Метрики, тип ClickHouse, тип numpy, нормализованное snake_case-имя, группа полей, порядок), никакой логики. Читаемость для менти несёт рендеренная таблица в доках, не модуль. Уточнение при исполнении (#36): нормализованное имя — имя источника, приведённое к нашему стилю, а не имя атрибута в модели данных. Слой DDS складывает свою модель и называет атрибуты по ней; dds.v_event эти имена берёт (раздел 7 мастер-спеки), но контракт их не диктует и тестами не сторожит.
  • Сторона хранилища пишется по документации, не генерируется. 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. Поток и пакет: сериализатор, проигрыватель, приёмники

Резолюция развилки «Архитектура», часть вторая.

  • Один канонический сериализатор, глупые приёмники. День-функция выдаёт упорядоченный поток канонических байтов — единственное место, где событие превращается в 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. Числа: объёмы, режимы, бюджеты

Резолюция развилки «Производительность»; порядки величин — исследование #31.

  • Средний модельный день — ~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 мастер-спеки): с манифестным решением напрашивается мини-манифест зернового мира — форму выбрать при нарезке.